TIL: Constraining TypeScript function parameters

Recently I was working on a generic table component. I wanted to allow developers to use it for any data type. I also wanted to handle common concerns like sorting and filtering contents of the table.

This got tricky to do in a type-safe way. Not all columns should be considered sortable, so the type system would need some way to differentiate column types. Data can be sorted in many ways, so I also needed to allow the user to define how sorts should occur, and the user should define this for every sortable column. I wouldn’t want to the user to forget to provide a sort function for a column that is sortable.

This is a complex problem. How do we make sure that:

  • The user can pass in arbitrary rows
  • The user can define columns for those rows
  • The user can define which columns are sortable
  • The user provides a sort function for each sortable column

My original approach was something like this:

type type Direction = "asc" | "desc"Direction = "asc" | "desc";

type 
type Sort = {
    key: string;
    direction: Direction;
}
Sort
= {
key: stringkey: string; direction: Directiondirection: type Direction = "asc" | "desc"Direction; }; type
type Column = {
    key: string;
    title: string;
    sortable?: boolean;
}
Column
= {
key: stringkey: string; title: stringtitle: string; sortable?: boolean | undefinedsortable?: boolean; }; type
type Props = {
    rows: object[];
    columns: Column[];
    sort: Sort;
    onSort: (newSort: Sort) => void;
}
Props
= {
rows: object[]rows: object[]; columns: Column[]columns:
type Column = {
    key: string;
    title: string;
    sortable?: boolean;
}
Column
[];
sort: Sortsort:
type Sort = {
    key: string;
    direction: Direction;
}
Sort
;
onSort: (newSort: Sort) => voidonSort: (newSort: SortnewSort:
type Sort = {
    key: string;
    direction: Direction;
}
Sort
) => void;
}; export function function Table(props: Props): voidTable(props: Propsprops:
type Props = {
    rows: object[];
    columns: Column[];
    sort: Sort;
    onSort: (newSort: Sort) => void;
}
Props
) {
// omitted for brevity }

This would work, but it’s easy to pass in the wrong types. For example:


const 
const rows: {
    name: string;
    birthday: string;
}[]
rows
= [{ name: stringname: "John", birthday: stringbirthday: "04/12/1997" }];
const
const columns: ({
    key: string;
    title: string;
    sortable: boolean;
} | {
    key: string;
    title: string;
    sortable?: undefined;
})[]
columns
= [
{ key: stringkey: "name", title: stringtitle: "Name", sortable: booleansortable: true }, { key: stringkey: "gender", title: stringtitle: "Gender" }, ]; const const sort: Sortsort:
type Sort = {
    key: string;
    direction: Direction;
}
Sort
= {
key: stringkey: "birthday", direction: Directiondirection: "asc", }; const const onSort: (sort: Sort) => voidonSort = (sort: Sortsort:
type Sort = {
    key: string;
    direction: Direction;
}
Sort
) => {
if (sort: Sortsort.key: stringkey === "birthday") { // sort } }; // pretend this is a React component and not a function call function Table(props: Props): voidTable({ rows: object[]rows, columns: Column[]columns, onSort: (newSort: Sort) => voidonSort, sort: Sortsort });

In the above example the user added a column that doesn’t exist on the data and didn’t pass in a function to handle sorting names. It’s really easy to make a mistake and misuse this component.

How can we make this better? Here’s what I came up with:

type 
type Column<K extends string> = {
    key: K;
    title: string;
    sortable: boolean;
}
Column
<function (type parameter) K in type Column<K extends string>K extends string> = {
key: K extends stringkey: function (type parameter) K in type Column<K extends string>K; title: stringtitle: string; sortable: booleansortable: boolean; }; type type Direction = "asc" | "desc"Direction = "asc" | "desc"; type
type Sort<K extends string> = {
    key: K;
    direction: Direction;
}
Sort
<function (type parameter) K in type Sort<K extends string>K extends string> = {
key: K extends stringkey: function (type parameter) K in type Sort<K extends string>K; direction: Directiondirection: type Direction = "asc" | "desc"Direction; }; // determine which columns are sortable type
type Sortable<T> = T extends {
    sortable: true;
} ? T : never
Sortable
<function (type parameter) T in type Sortable<T>T> = type Extract<T, U> = T extends U ? T : never
Extract from T those types that are assignable to U
Extract
<function (type parameter) T in type Sortable<T>T, { sortable: truesortable: true }>;
type
type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]> = {
    rows: R[];
    cols: C[];
    sort: Sort<SortableKey>;
    onSort: (newSort: Sort<SortableKey>) => void;
}
Props
<function (type parameter) R in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>R extends object, function (type parameter) C in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>C extends
type Column<K extends string> = {
    key: K;
    title: string;
    sortable: boolean;
}
Column
<type Extract<T, U> = T extends U ? T : never
Extract from T those types that are assignable to U
Extract
<keyof function (type parameter) R in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>R, string>>, function (type parameter) SortableKey in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>SortableKey extends
type Sortable<T> = T extends {
    sortable: true;
} ? T : never
Sortable
<function (type parameter) C in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>C>["key"]> = {
rows: R[]rows: function (type parameter) R in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>R[]; cols: C[]cols: function (type parameter) C in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>C[]; sort: Sort<SortableKey>sort:
type Sort<K extends string> = {
    key: K;
    direction: Direction;
}
Sort
<function (type parameter) SortableKey in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>SortableKey>;
onSort: (newSort: Sort<SortableKey>) => voidonSort: (newSort: Sort<SortableKey>newSort:
type Sort<K extends string> = {
    key: K;
    direction: Direction;
}
Sort
<function (type parameter) SortableKey in type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>SortableKey>) => void;
}; // enforce that the passed in sort's `key` allows all _sortable_ columns function function Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidTable<function (type parameter) R in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidR extends object, function (type parameter) C in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidC extends
type Column<K extends string> = {
    key: K;
    title: string;
    sortable: boolean;
}
Column
<type Extract<T, U> = T extends U ? T : never
Extract from T those types that are assignable to U
Extract
<keyof function (type parameter) R in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidR, string>>, function (type parameter) SortableKey in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidSortableKey extends
type Sortable<T> = T extends {
    sortable: true;
} ? T : never
Sortable
<function (type parameter) C in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidC>["key"]>({
rows: R[]rows, cols: C[]cols, sort: Sort<SortableKey>sort, onSort: (newSort: Sort<SortableKey>) => voidonSort, }:
type Props<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]> = {
    rows: R[];
    cols: C[];
    sort: Sort<SortableKey>;
    onSort: (newSort: Sort<SortableKey>) => void;
}
Props
<function (type parameter) R in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidR, function (type parameter) C in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidC, function (type parameter) SortableKey in Table<R extends object, C extends Column<Extract<keyof R, string>>, SortableKey extends Sortable<C>["key"]>({ rows, cols, sort, onSort, }: Props<R, C, SortableKey>): voidSortableKey>) {
// omitted for brevity } const
const rows: {
    name: string;
    birthday: string;
}[]
rows
= [
{ name: stringname: "John", birthday: stringbirthday: "04/12/1997", }, ]; const
const cols: ({
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
})[]
cols
= [
{ key: "name" | "birthday"key: "name", title: stringtitle: "Name", sortable: booleansortable: false, }, { key: "name" | "birthday"key: "birthday", title: stringtitle: "Birthday", sortable: booleansortable: true, }, ] satisfies
type Column<K extends string> = {
    key: K;
    title: string;
    sortable: boolean;
}
Column
<type Extract<T, U> = T extends U ? T : never
Extract from T those types that are assignable to U
Extract
<keyof (typeof
const rows: {
    name: string;
    birthday: string;
}[]
rows
)[number], string>>[];

Here’s what it looks like in action:

// this should work
function Table<{
    name: string;
    birthday: string;
}, {
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
}, "birthday">({ rows, cols, sort, onSort, }: Props<{
    name: string;
    birthday: string;
}, {
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
}, "birthday">): void
Table
({
rows: {
    name: string;
    birthday: string;
}[]
rows
,
cols: ({
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
})[]
cols
,
sort: Sort<"birthday">sort: { key: "birthday"key: "birthday", direction: Directiondirection: "desc" }, onSort: (newSort: Sort<"birthday">) => voidonSort: (key: Sort<"birthday">key) => {}, }); // should not work -- trying to sort on a column that isn't sortable
function Table<{
    name: string;
    birthday: string;
}, {
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
}, "birthday">({ rows, cols, sort, onSort, }: Props<{
    name: string;
    birthday: string;
}, {
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
}, "birthday">): void
Table
({
rows: {
    name: string;
    birthday: string;
}[]
rows
,
cols: ({
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
})[]
cols
,
sort: Sort<"birthday">sort: { key: "name",
Type '"name"' is not assignable to type '"birthday"'.
direction: Directiondirection: "desc", }, onSort: (newSort: Sort<"birthday">) => voidonSort: (sort: Sort<"birthday">sort:
type Sort<K extends string> = {
    key: K;
    direction: Direction;
}
Sort
<"birthday">) => {
// do sort }, }); // should not work -- not providing a sort function for all sortable columns
function Table<{
    name: string;
    birthday: string;
}, {
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
}, "birthday">({ rows, cols, sort, onSort, }: Props<{
    name: string;
    birthday: string;
}, {
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
}, "birthday">): void
Table
({
rows: {
    name: string;
    birthday: string;
}[]
rows
,
cols: ({
    key: "name";
    title: string;
    sortable: false;
} | {
    key: "birthday";
    title: string;
    sortable: true;
})[]
cols
,
sort: Sort<"birthday">sort: { key: "birthday"key: "birthday", direction: Directiondirection: "desc", }, onSort: (sort: Sort<"name">sort:
type Sort<K extends string> = {
    key: K;
    direction: Direction;
}
Sort
<"name">) => {
Type '(sort: Sort<"name">) => void' is not assignable to type '(newSort: Sort<"birthday">) => void'. Types of parameters 'sort' and 'newSort' are incompatible. Type 'Sort<"birthday">' is not assignable to type 'Sort<"name">'. Type '"birthday"' is not assignable to type '"name"'.
// do sort }, }); // should not work -- trying to create a column that doesn't exist on the rows
function Table<{
    name: string;
    birthday: string;
}, Column<"name" | "birthday">, never>({ rows, cols, sort, onSort, }: Props<{
    name: string;
    birthday: string;
}, Column<"name" | "birthday">, never>): void
Table
({
rows: {
    name: string;
    birthday: string;
}[]
rows
,
cols: Column<"name" | "birthday">[]cols: [ ...cols,
Type '{ key: "name"; title: string; sortable: false; } | { key: "birthday"; title: string; sortable: true; } | { key: "gender"; title: string; sortable: false; }' is not assignable to type 'Column<"name" | "birthday">'. Type '{ key: "gender"; title: string; sortable: false; }' is not assignable to type 'Column<"name" | "birthday">'. Types of property 'key' are incompatible. Type '"gender"' is not assignable to type '"name" | "birthday"'.
{ key: "gender",
Type '"gender"' is not assignable to type '"name" | "birthday"'.
title: stringtitle: "Gender", sortable: booleansortable: false, }, ], sort: Sort<never>sort: { key: "birthday",
Type 'string' is not assignable to type 'never'.
direction: Directiondirection: "desc", }, onSort: (newSort: Sort<never>) => voidonSort: (sort: Sort<"name">sort:
type Sort<K extends string> = {
    key: K;
    direction: Direction;
}
Sort
<"name">) => {
// do sort }, });

It handles all of the cases I was concerned about. You can’t pass in an invalid sorting configuration, column configuration, and the user must handle all of the sorting cases that they claim to support.

This was one of the more complex TypeScript types that I’ve written. I’ve found TypeScript to be incredibly flexible, though it requires quite a bit of time to understand how to effectively use the type system.

If you’re interesting in learning more I’d highly suggest checking out Type-Level TypeScript and/or Total TypeScript.

Recent posts from blogs that I like

Live blog: the 12th day of OpenAI - "Early evals for OpenAI o3"

via Simon Willison

Interiors by Design: Bedrooms

One of the most private areas in a house or apartment, shown here by Degas, Maximilien Luce, Pierre Bonnard, Édouard Vuillard, Eric Ravilious and others.

via The Eclectic Light Company

Things I learned working with artists

As I said in “Lessons I learned working at an art gallery,” I had several observations that I couldn’t fit into that post—so lets continue today.

via Henrik Karlsson