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 : neverExtract from T those types that are assignable to UExtract<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 : neverExtract from T those types that are assignable to UExtract<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 : neverExtract from T those types that are assignable to UExtract<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 : neverExtract from T those types that are assignable to UExtract<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", 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">) => { // 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, {
key: "gender", title: stringtitle: "Gender",
sortable: booleansortable: false,
},
],
sort: Sort<never>sort: {
key: "birthday", 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.