loading...

Notes on TypeScript: Recursive Type Aliases and Immutability

busypeoples profile image A. Sharif ・4 min read

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples in this post are based on TypeScript 3.7.2.


Recursive Types

Prior to the 3.7 release, it was was not possible to simply write:

type Item = [string, number, Item[]];

The compiler would complain with: Type alias 'Item' circularly references itself.. This was suboptimal, as there are enough use cases where recursive types would be useful. On a side note it was possible for a type alias to reference itself via a property:

type Item<T> = {
    value: T;
    reference: Item<T>;
}

There was still a way to achieve this, but it required to fallback to using interface. This is how the same type would have been defined before the 3.7 release:

type Item = [string, number, Items[]];

interface Items extends Item {};

It required developers to switch back and forth between type and interface definitions and was more complicated than necessary. With 3.7 we can write:

type Item = [string, number, Item[]];

This is a useful improvement.


Immutability

Now that we learned about the recursive type aliases, let's create an immutable type that we can use to add more guarantees into our application code.

We might want to define a specific Shape type for example.

type Shape = {
    color: string;
    configuration: {
        height: number;
        width: number;
    }
};

With the release of TypeScript 3.4, const assertions where introduced. This would enable us to make a JavaScript object immutable.

const shape = {
    color: 'green',
    configuration: {
        height: 100,
        width: 100,
    }
} as const;

Which would result in:


const shape: {
    readonly color: "green";
    readonly configuration: {
        readonly height: 100;
        readonly width: 100;
    }
};

as const converts the properties of any object to readonly, which would guarantee that our shape object is immutable.

shape.color = 'blue'; 
// Error!  Cannot assign to 'color' because it is a read-only property.
shape.configuration.height = 101;
// Error! Cannot assign to 'height' because it is a read-only property.

But there are limitations with this approach. What if we had a property containing an array for example?

const numberArray: number[] = [];
const shape = {
    color: 'green',
    attributes: numberArray,
    configuration: {
        height: 100,
        width: 100,
    }
} as const;

// This would work...
shape.attributes.push(1);

There is another limitation, for example when working with functions. See the next example.

const transformShape = (shape: Shape) => {
    shape.configuration.height = 101;
    return shape;
}

// Would work...
transformShape(shape);

We might add the Readonly type to ensure that we can't change any values.

const transformShape = (shape: Readonly<Shape>) => {
    shape.configuration.height = 101;
    return shape;
}

// Would work...
transformShape(shape);

From the above example we can note that Readonly would only help us if we tried to change any top level properties.

const transformShape = (shape: Readonly<Shape>) => {
    shape.color = 'red';
    // Error! Cannot assign to 'color' because it is a read-only property.
    return shape;
}

The Readonly type is defined like the following:

type Readonly<T> = { readonly [P in keyof T]: T[P]; }

The problem is that it doesn't work with deep nested structures.

To work around the issue we can build our own MakeReadOnly type, that should ensure we can't mutate any deeply nested properties inside a function body.

As we learned about recursive type aliases just before, we can now create an immutable type definition.

type MakeReadOnly<Type> = {
  readonly [Key in keyof Type]: MakeReadOnly<Type[Key]>;
};

Now that we have our own type definition let's see how we can apply this as compared to the previous examples.

const shape: MakeReadOnly<Shape> = {
    color: 'green',
    configuration: {
        height: 100,
        width: 100,
    }
};

shape.color = 'blue';
// Error! Cannot assign to 'color' because it is a read-only property.
shape.configuration.height = 101;
// Error! Cannot assign to 'height' because it is a read-only property.

We get the same results like in the previous examples, using const assertions. This might not be adding any additional value. But let's see what we gain by using our newly defined MakeReadOnly type when working with functions.

const transformShape = (shape: MakeReadOnly<Shape>) => {
    shape.color = 'red';
    // Error! Cannot assign to 'color' because it is a read-only property.
    shape.configuration.height = 101;
    //Error! Cannot assign to 'height' because it is a read-only property
    return shape;
}

As we can see from the above example, it's not possible to change the values of any nested properties anymore. This gives us more control on how objects are handled inside function bodies.

In summary there is no concise way to guarantee immutability in TypeScript. Some additional work is needed to ensure that at least in specific parts of the application mutating an object or array is limited.

Links

Official Release Notes on Recursive Type Aliases

Offical Release Notes on const assertions

Marius Schulz: Const Assertions in Literal Expressions in TypeScript

If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Discussion

pic
Editor guide