loading...
Bornfight

TypeScript compile-time interface validation with tagged unions

renatoruk profile image Renato Ruk ・3 min read

If you work with TypeScript, you probably love the static type checking it provides. It’s a very powerful programming tool that helps us detect bugs before they can even run, by showing us compile errors.

Using interfaces is by itself very effective technique for writing code that is correct by a specified contract. But, what if we have similar contracts that define some intrinsic behaviour of a class or a function and we want to combine their definitions?

Let's image we're building a table with React that shows a list of records from the backend API. The component receives columnData of type TableColumnData[] as a prop, which is an array of config objects that determine how each column should be built and how it should behave. The content of the column is specified with the dataIndex field of the interface, which renders the value of matching key from each record passed to the table. As the data is passed from the backend API, it can have thousands of records, therefore we need to add the ability to search only the ones we need.

We can then add a filter property on the column that will, if not left empty, render a text input in our column header. By submitting the search, the table will do a request on the API with the new value of the specified field.

The overly simplified version of the TableColumnData interface could look like this:

export interface TableColumnData {
    dataIndex: string;
    title: string;
    filter?: TableColumnFilter;
}

and the interface for the filter can be specified like this:

export interface TableColumnFilter {
    field: string;
}

Finally, table should be used similarly to this (pseudo-react-code):

const MyTable: React.FC = (props) => {
    const columnData = [
        {
            title: "name",
            dataIndex: "name",
            filter: {
                field: "name",
            },
        },
        {
            title: "birthday",
            dataIndex: "birthday",
        },
    ];

    return <ResourceTable {...props} columnData={columnData} />;
}

The filtering made the user experience of our table richer, but what if we want to add new types of filters, such as, for example, date filter?

We can create another interface for that filter type, rename the TableColumnFilter to TableColumnTextFilter and combine the two filter types together in a union.

export interface TableColumnDateFilter {
    field: string;
    showHours: boolean;
}

Now, TableColumnFilter can be defined like this:

export type TableColumnFilter = TableColumnTextFilter | TableColumnDateFilter;

Our table still works, but now there is no way of knowing we used the proper interface for the filter type.

const MyTable: React.FC = (props) => {
    const columnData = [
        {
            title: "name",
            dataIndex: "name",
            filter: {
                field: "name",
                // does not make much sense
                showHours: true,
            },
        },
        {
            title: "birthday",
            dataIndex: "birthday",
            filter: {
                field: "birthday",
            },
        },
    ];

    return <ResourceTable {...props} columnData={columnData} />;
}

We can then narrow the types further by creating an enum. That enum will tell the TypeScript compiler which filter type is used, and therefore it will hint us what the rest of the interface should look like.

export enum ColumnFilterType {
    Text = "text",
    Date = "date",
}

export interface TableColumnTextFilter {
    type: ColumnFilterType.Text;
    field: string;
}


export interface TableColumnDateFilter {
    type: ColumnFilterType.Date;
    field: string;
    showHours: boolean;
}

This pattern is called discriminated union, aka tagged union or algebraic data type.

In our scenario, the discriminant is the type field, which will be used to differentiate the types.

Now, expanding our table example with the type field, we get a compile error when using enums.

const MyTable: React.FC = (props) => {
    const columnData = [
        {
            title: "name",
            dataIndex: "name",
            filter: {
                field: "name",
                showHours: true,
                type: ColumnFilterType.Text,
            },
        },
        {
            title: "birthday",
            dataIndex: "birthday",
            filter: {
                field: "birthday",
                type: ColumnFilterType.Date,
            },
        },
    ];

    return <ResourceTable {...props} columnData={columnData} />;
}

The error is Type 'ColumnFilterType' is not assignable to type 'ColumnFilterType.Date'. This is expected as TypeScript thinks we use ColumnFilterType as a value for the type field.

We can prevent this by using const assertion and prevent further type widening.

const MyTable: React.FC = (props) => {
    const columnData = [
        {
            title: "name",
            dataIndex: "name",
            filter: {
                field: "name",
                type: ColumnFilterType.Text as const,
            },
        },
        {
            title: "birthday",
            dataIndex: "birthday",
            filter: {
                field: "birthday",
                type: ColumnFilterType.Date as const,
                showHours: true,
            },
        },
    ];

    return <ResourceTable {...props} columnData={columnData} />;
}

Now, using the interface incorrectly will result in a compile error, which may help you prevent runtime errors if that internal behaviour is determined by a correct interface. To me, this ability to have pre-compile validation of implementation is what makes typed languages really stand out. They are especially helpful in collaboration and refactoring.

Have you had a chance to used tagged unions before? Do you have a TypeScript feature that you could not live without? Share those in the comments below! ✌🏻

Posted on by:

renatoruk profile

Renato Ruk

@renatoruk

Programming, solving problems, learning, teaching, music.

Bornfight

Digital Innovation Company that creates custom software, digital products, mobile apps, websites and interactive solutions.

Discussion

markdown guide