TypeScript is now a must-have for creating type-safe code and proves highly beneficial for crafting libraries.
BUT, doing full typesafe libraries can become quickly tricky.
To understand the rest of the article, you have to know what are Generics .
For the examples, I will use the following data:
type Person = {
firstname: string;
lastname: string;
age: number;
};
In my day to day job, I work a lot with Datatable
.
A table can be described by:
data
: values that you want to display.columns
: columns of your table.
For example we want to display a list of persons.
A column can be of 2 types:
- it corresponds to a key of the data. For example, render
firstname
->accessor
function - it is a combination of multiple keys of the data. For example, the full name which is the concatenation of
firstname
andlastname
->display
function
Note: To start we will focus on the first type
In this case we want to link the column name with the data keys:
type Datatable<TData extends Record<string, any>> = {
data: TData[];
columns: {
name: keyof TData;
}[];
};
Nice it works, but in my column I want to define a render
function that will define how to display the data:
type Datatable<TData extends Record<string, any>> = {
data: TData[];
columns: {
name: keyof TData;
// columnValue is the value of the row for
// the current column name
render?: (
columnValue: any,
rowValues: TData,
) => JSX.Element;
}[];
};
There is multiple problem of this type:
- it’s not fully typesafe because of the
any
- the second problem is visible in the value level
Let’s see what happens in the the value level:
const datatable: Datatable<Person> = {
persons,
columns: [{ name: 'firstname' }, { name: 'age' }],
};
We are forced to pass a type argument!
The Typescript Wizard Matt Pocock told in a video:
The generics cannot exist at the scope of objects. They have to exist within a function.
Yep the solution to fix the second problem is to make a function:
function createDatatable<
TData extends Record<string, unknown>,
>(values: Datatable<TData>) {
return values;
}
Which gives us:
const datatable = createDatatable({
data: persons,
columns: [{ name: 'firstname' }, { name: 'age' }],
});
Noice, the inference from the data is working now.
But we still have the second problem: the any
problem.
For the moment, we can do a mapped type to get all columns possibilities:
// Here we do a mapped typed, and retrieve only values
type Columns<TData extends Record<string, any>> = {
[TName in keyof TData]: {
name: TName;
render?: (
columnValue: TData[TName],
rowValues: TData,
) => JSX.Element;
};
}[keyof TData][];
type Datatable<TData extends Record<string, any>> = {
data: TData[];
columns: Columns<TData>;
};
And now we got a full type safe:
const datatable = createDatatable({
data: persons,
columns: [
{
name: "firstname",
render: (value, values) => <span>{value}</span>,
// ^? string
},
{
name: "age",
render: (value, values) => <span>{value}</span>,
// ^? number
},
],
});
Well, no!
We are going to see with the second type of column that it’s not so easy to make it works.
The second type of column, is column that does have a name corresponding to none of the key of the data.
In this case, the render
function has only one parameter which are the row values.
To handle this, we can change the Columns
type with:
type Columns<TData extends Record<string, any>> = (
| {
[TName in keyof TData]: {
name: TName;
render?: (
columnValue: TData[TName],
rowValues: TData,
) => JSX.Element;
};
}[keyof TData]
| {
// The name can be anything
name: string;
// We have only access to row values
render?: (rowValues: TData) => JSX.Element;
}
)[];
An in the value world:
const datatable = createDatatable({
data: persons,
columns: [
{
name: "firstname",
render: (value, values) => <span>{value}</span>,
// ^? string
},
{
name: "age",
render: (value, values) => <span>{value}</span>,
// ^? number
},
{
name: "fullName",
render: (values) => <span>{values.firstname} {values.lastname}</span>
// ^? Person
}
],
});
It works pretty well, isn’t it?
But you can see that I don’t use the values
parameter in the two first render
function, so let’s remove it:
const datatable = createDatatable({
data: persons,
columns: [
{
name: "firstname",
render: value => <span>{value}</span>,
// ^? any
},
{
name: "age",
render: value => <span>{value}</span>,
// ^? any
},
{
name: "fullName",
render: (values) => <span>{values.firstname} {values.lastname}</span>
// ^? Person
}
],
});
Ouch, any
are back :(
This is because Typescript infers the right type to use thanks to the callback signature.
Here it sees that both signature can match so it puts any
to match both.
We are kinda stuck now. I don’t think there is no other solution than rethink the API.
Imagine that createDatatable
takes only one parameter:
data
: The data of theDatatable
And returns, 2 functions:
accessor
: function to create a column that access to a key of the datadisplay
: function to create a column that display a combination of keys of the data
At the same time, let’s rename the function to createColumnsHelper
.
type CreateColumnsHelper<
TData extends Record<string, any>,
> = {
accessor: <TName extends keyof TData>(
name: TName,
options: {
render?: (
value: TData[TName],
rowValues: TData,
) => JSX.Element;
},
) => {
name: TName;
render?: (
value: TData[TName],
rowValues: TData,
) => JSX.Element;
};
display: (
column: { name: string, render?: (rowValues: TData) => JSX.Element },
) => {
name: string;
render?: (rowValues: TData) => JSX.Element;
};
};
function createColumnsHelper<
TData extends Record<string, any>,
>(data: TData): CreateColumnsHelper<TData> {
return {
accessor: (name, options) => ({
name,
...options,
}),
display: (column) => column,
};
}
const columnsHelper = createColumnHelper(persons);
const columns = [
columnsHelper.accessor("firstname"),
columnsHelper.display({
name: "fullname",
render: (values) => (
<span>
{values.firstname} {values.lastname}
</span>
),
}),
];
And here we go, we have a fully type safe function. Because the data
paremeter is “not used”
we could remove it, but in this case the user would have to pass the type argument.
But I would probably let it because we can think:
If the user needs to pass a type parameter then it’s not fully type safe.
Remember that helper function is something common in Typescript libraries. For example, you may have recognized that
my example is based on @tanstack/table
from the boss Tanner Linsley.
We can see the same pattern in other libraries : @tanstack/form
, ts-rest
, trpc
, …
🔥 Tanstack Table .
📼 Advanced Typescript: Let’s Learn Generics with Jason Lengstorf and Matt Pocock.
⚡ TS-REST .
⭐ tRPC .
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.