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: string
key: string;
direction: Direction
direction: type Direction = "asc" | "desc"
Direction;
};
type type Column = {
key: string;
title: string;
sortable?: boolean;
}
Column = {
key: string
key: string;
title: string
title: string;
sortable?: boolean | undefined
sortable?: 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: Sort
sort: type Sort = {
key: string;
direction: Direction;
}
Sort;
onSort: (newSort: Sort) => void
onSort: (newSort: Sort
newSort: type Sort = {
key: string;
direction: Direction;
}
Sort) => void;
};
export function function Table(props: Props): void
Table(props: Props
props: 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: string
name: "John", birthday: string
birthday: "04/12/1997" }];
const const columns: ({
key: string;
title: string;
sortable: boolean;
} | {
key: string;
title: string;
sortable?: undefined;
})[]
columns = [
{ key: string
key: "name", title: string
title: "Name", sortable: boolean
sortable: true },
{ key: string
key: "gender", title: string
title: "Gender" },
];
const const sort: Sort
sort: type Sort = {
key: string;
direction: Direction;
}
Sort = {
key: string
key: "birthday",
direction: Direction
direction: "asc",
};
const const onSort: (sort: Sort) => void
onSort = (sort: Sort
sort: type Sort = {
key: string;
direction: Direction;
}
Sort) => {
if (sort: Sort
sort.key: string
key === "birthday") {
// sort
}
};
// pretend this is a React component and not a function call
function Table(props: Props): void
Table({ rows: object[]
rows, columns: Column[]
columns, onSort: (newSort: Sort) => void
onSort, sort: Sort
sort });
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 string
key: function (type parameter) K in type Column<K extends string>
K;
title: string
title: string;
sortable: boolean
sortable: 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 string
key: function (type parameter) K in type Sort<K extends string>
K;
direction: Direction
direction: 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 UExtract<function (type parameter) T in type Sortable<T>
T, { sortable: true
sortable: 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 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>) => void
onSort: (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>): void
Table<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>): void
R 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>): void
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 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>): void
R, 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>): void
SortableKey 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>): void
C>["key"]>({
rows: R[]
rows,
cols: C[]
cols,
sort: Sort<SortableKey>
sort,
onSort: (newSort: Sort<SortableKey>) => void
onSort,
}: 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>): void
R, 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>): void
C, 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>): void
SortableKey>) {
// omitted for brevity
}
const const rows: {
name: string;
birthday: string;
}[]
rows = [
{
name: string
name: "John",
birthday: string
birthday: "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: string
title: "Name",
sortable: boolean
sortable: false,
},
{
key: "name" | "birthday"
key: "birthday",
title: string
title: "Birthday",
sortable: boolean
sortable: 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 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: Direction
direction: "desc" },
onSort: (newSort: Sort<"birthday">) => void
onSort: (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: Direction
direction: "desc",
},
onSort: (newSort: Sort<"birthday">) => void
onSort: (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: Direction
direction: "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: string
title: "Gender",
sortable: boolean
sortable: false,
},
],
sort: Sort<never>
sort: {
key: "birthday", direction: Direction
direction: "desc",
},
onSort: (newSort: Sort<never>) => void
onSort: (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.