typescript
57 lines · 8 steps
A type-safe SQL query builder in TypeScript
A generic builder chains type-checked clauses and emits parameterized SQL to prevent injection.
Explained by
highlit
1type Operator = "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
2
3type Row = Record<string, unknown>;
4
5type Condition<T> = {
6 column: keyof T & string;
7 operator: Operator;
8 value: unknown;
9};
10
11class QueryBuilder<T extends Row> {
12 private conditions: Condition<T>[] = [];
13 private columns: (keyof T & string)[] = [];
14 private orderBy?: { column: keyof T & string; dir: "ASC" | "DESC" };
15 private limitCount?: number;
16
17 constructor(private readonly table: string) {}
18
19 select(...columns: (keyof T & string)[]): this {
20 this.columns = columns;
21 return this;
22 }
23
24 where(column: keyof T & string, operator: Operator, value: unknown): this {
25 this.conditions.push({ column, operator, value });
26 return this;
27 }
28
29 order(column: keyof T & string, dir: "ASC" | "DESC" = "ASC"): this {
30 this.orderBy = { column, dir };
31 return this;
32 }
33
34 limit(count: number): this {
35 this.limitCount = count;
36 return this;
37 }
38
39 build(): { sql: string; params: unknown[] } {
40 const cols = this.columns.length ? this.columns.join(", ") : "*";
41 const params: unknown[] = [];
42 let sql = `SELECT ${cols} FROM ${this.table}`;
43
44 if (this.conditions.length) {
45 const clauses = this.conditions.map((c) => {
46 params.push(c.value);
47 return `${c.column} ${c.operator} $${params.length}`;
48 });
49 sql += ` WHERE ${clauses.join(" AND ")}`;
50 }
51
52 if (this.orderBy) sql += ` ORDER BY ${this.orderBy.column} ${this.orderBy.dir}`;
53 if (this.limitCount != null) sql += ` LIMIT ${this.limitCount}`;
54
55 return { sql, params };
56 }
57}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Constraining a generic to a row shape lets column names be validated at compile time.
- 2Returning `this` from each method enables fluent chaining until a terminal build step.
- 3Pushing values into a params array and emitting placeholders keeps SQL parameterized and injection-safe.
Related explainers
typescript
import { DynamicModule, Module, Provider } from '@nestjs/common'; import Redis, { RedisOptions } from 'ioredis'; export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
Building a dynamic Redis module in NestJS
dependency-injection
dynamic-modules
provider-tokens
Intermediate
8 steps
typescript
import { Directive, ElementRef, HostListener, Input, OnDestroy, OnInit } from '@angular/core'; @Directive({ selector: '[appTrapFocus]',
Building a focus-trap directive in Angular
accessibility
focus-management
dom
Intermediate
9 steps
rust
use std::env; use std::time::Duration; #[derive(Debug, Clone)]
Loading typed config from environment variables in Rust
configuration
error-handling
generics
Intermediate
7 steps
typescript
type AsyncFn<A extends unknown[], R> = (...args: A) => Promise<R>; interface MemoizeOptions<A extends unknown[]> { keyFn?: (...args: A) => string;
Memoizing async functions with TTL in TypeScript
memoization
generics
promises
Advanced
8 steps
typescript
import { Injectable, Scope, Inject } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express';
Request-scoped context service in NestJS
dependency-injection
request-scope
immutability
Intermediate
8 steps
rust
use std::time::Duration; use tokio::time::sleep; #[derive(Debug)]
Async retry with exponential backoff in Rust
retry
exponential-backoff
async
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-type-safe-sql-query-builder-in-typescript-explained-typescript-8220/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.