TypeScript: type-safe select, where, and orderBy

Write small query helpers whose column names are checked by the compiler with keyof.

TypeScript
type Row = { id: number; name: string; dept: string; salary: number };

const table: Row[] = [
  { id: 1, name: "Ada", dept: "Eng", salary: 120 },
  { id: 2, name: "Bo", dept: "Ops", salary: 90 },
  { id: 3, name: "Cy", dept: "Eng", salary: 100 },
  { id: 4, name: "Di", dept: "Ops", salary: 80 },
];

function where<T>(rows: T[], predicate: (row: T) => boolean): T[] {
  return rows.filter(predicate);
}

function orderBy<T, K extends keyof T>(rows: T[], key: K): T[] {
  return [...rows].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0));
}

function select<T, K extends keyof T>(rows: T[], ...keys: K[]): Pick<T, K>[] {
  return rows.map((row) => {
    const picked = {} as Pick<T, K>;
    for (const key of keys) {
      picked[key] = row[key];
    }
    return picked;
  });
}

const wellPaid = where(table, (row) => row.salary >= 100);
const sorted = orderBy(wellPaid, "name");
console.log(JSON.stringify(select(sorted, "name", "salary")));
console.log(JSON.stringify(select(orderBy(table, "salary"), "name")));
Output
[{"name":"Ada","salary":120},{"name":"Cy","salary":100}]
[{"name":"Di"},{"name":"Bo"},{"name":"Cy"},{"name":"Ada"}]

Explanation

A relational query filters rows, sorts them, and chooses columns. In TypeScript a table can be an array of objects, and the type of the row describes its columns. The generic constraint K extends keyof T says that a column name must be one of the row type's property names, so a misspelled column is a compile-time error instead of an empty result at run time.

Pick<T, K> is the type of an object with only the chosen columns, so select returns rows with exactly those properties. The helpers are the in-memory equivalent of WHERE, ORDER BY, and the column list of SELECT. Types are erased when the program runs, so data coming from a real database or an API still needs runtime validation.

Explore the dry run
Guided walkthroughReady
1type Row = { id: number; name: string; dept: string; salary: number };
2
3const table: Row[] = [
4 { id: 1, name: "Ada", dept: "Eng", salary: 120 },
5 { id: 2, name: "Bo", dept: "Ops", salary: 90 },
6 { id: 3, name: "Cy", dept: "Eng", salary: 100 },
7 { id: 4, name: "Di", dept: "Ops", salary: 80 },
8];
9
10function where<T>(rows: T[], predicate: (row: T) => boolean): T[] {
11 return rows.filter(predicate);
12}
13
14function orderBy<T, K extends keyof T>(rows: T[], key: K): T[] {
15 return [...rows].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0));
16}
17
18function select<T, K extends keyof T>(rows: T[], ...keys: K[]): Pick<T, K>[] {
19 return rows.map((row) => {
20 const picked = {} as Pick<T, K>;
21 for (const key of keys) {
22 picked[key] = row[key];
23 }
24 return picked;
25 });
26}
27
28const wellPaid = where(table, (row) => row.salary >= 100);
29const sorted = orderBy(wellPaid, "name");
30console.log(JSON.stringify(select(sorted, "name", "salary")));
31console.log(JSON.stringify(select(orderBy(table, "salary"), "name")));

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
K must be a real column name of T. orderBy(table, "nme") would not compile.—
—
The result type contains only the selected columns.—
—
WHERE: keep Ada (120) and Cy (100).wellPaid = Ada, Cy
—
ORDER BY name, then SELECT name, salary.wellPaid = Ada, Cy
[{"name":"Ada","salary":120},{"name":"Cy","salary":100}]
Sorting by salary ascending gives Di, Bo, Cy, Ada.wellPaid = Ada, Cy
[{"name":"Ada","salary":120},{"name":"Cy","salary":100}]
[{"name":"Di"},{"name":"Bo"},{"name":"Cy"},{"name":"Ada"}]

Cost and common mistakes

where and select are O(n); orderBy is O(n log n).

  • The comparison a[key] < b[key] works for numbers and strings, but the compiler cannot guarantee the column is comparable. Restrict K further if needed.
  • Types are checked at compile time only. Rows from a JSON file or database still need runtime checks.
  • sort mutates the array, so the helper copies it first with the spread operator.

Try it yourself

How would you add a limit helper?

Reveal answer and reasoning

function limit<T>(rows: T[], count: number): T[] { return rows.slice(0, count); }

Further reading