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
Top comments (0)