Go to main content
December 5, 2023
Cover image

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 and lastname -> 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
    },
  ],
});

Is it the final implementation?

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 the Datatable

And returns, 2 functions:

  • accessor: function to create a column that access to a key of the data
  • display: 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>
    ),
  }),
];

Success image


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.