TypeScript Coding Challenge #3 – Type Chainable Options

In all unique characters and string compression we started with 2 easy coding challenges. But they were not really TypeScript specific. They could have been programmed in JavaScript too and it wouldn’t have made much difference. So, what challenges are really TypeScript specific? Those that involve types!

Today we want to create types for an object with chainable method calls. Specifically, we need to provide types for two functions:

option(key, value)

For key of option we can only pass strings while the value can be anything. In the end we need to provide a type or interface for Chainable as used below:

declare const config: Chainable;

const result: Result  = config
    .option('foo', 123)
    .option('name', 'type-challenges')
    .option('bar', { value: 'Hello World' })

interface Result {
    foo: number,
    name: string,
    bar: {
        value: string

Credits to antfu (https://github.com/type-challenges/type-challenges) for this and more great examples.

Simple solution

Let’s start by creating a simple interface Chainable with two properties that are functions. For option the key parameter is of type string while value can be any. The result of the option function is again of type Chainable. For the second method get we just define the return type Result defined above.

interface Chainable {
    option: (key: string, value: any) => Chainable,
    get: () => Result

When checking our IDE everything looks fine. No red squiggly lines:

The variable result does conform to the type Result.

The result conforms to the type Result and the methods on the Chainable config are typed. But we can do better than that.

Solution without explicit return type

The goal is always to have code that requires low maintenance and easy extension. Types are no different in that regard. We can actually enhance the types for our Chainable interface and get rid of Result. But first let’s see what happens if we remove the Result type from result.

The type of result is any object {}

The method get is returning {} as type and therefore also our result is of type object. Not very safe. As a consequence we also can’t access its properties:

Property access on result.foo not working

Now let’s introduce some generics to type Chainable and our result:

interface Chainable<P = {}> {
    option<K extends string, V>(key: K, value: V): Chainable<P & { [key in K]: V }>,
    get: () => P

declare const config: Chainable;

const result = config
    .option('foo', 123)
    .option('name', 'type-challenges')
    .option('bar', { value: 'Hello World' })

The Chainable interface gets a generic type parameter P with a default value. This P also “connects” the two methods. The key parameter in option is following the generic type parameter K which is constrained to string. This makes sense because this key will end up as object key for the output where we want to have string as type.
On the return side of option we make use of Intersection Types, generics and recursion. P gets “extended” with a property having key of type K and a type V.
The intersection is important to type chainable method calls in TypeScript and not lose the type from previous method calls. I first ended up with a type that only had bar as property.
Finally the method get is returning something of type P.

After that change our IDE is happy too and result is properly typed:

Variant with generics that has the key names from the option method as properties.

We not only see the properties on result (bar, foo, name) but also the type of the property ({value: string}, number, string). Hovering over result shows the complete type:

Complete result type displayed in the IDE with the intersections.

This is it for now. You’ve learnt to type chainable methods in TypeScript. Please find the sources on GitHub. If you have any questions or ideas for further TypeScript challenges, you can reach out to me on Twitter @ronnieschaniel.