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.
TL;DR:
Liquid error: internal
Type Level Programming
This is the third part, where we focus on type level programming in TypeScript. For a better understanding of this post, it might be helpful to have a basic understanding of mapped and lookup types as well as conditional types. Additionally reading the first two posts about type level programming can be helpful too.
You can find the following posts here:
Examples
In this part, we're going to write some helper functions that enables us to do type transformations based on a specified type. There are cases where we might want to make all properties of a specific type readonly
or optional
or required
. There also might be siutations where we need to transform all properties of a defined type to another type.
Let's assume we have User
type.
type User = {
name: string;
points: number;
active: boolean;
};
How can convert anything of type boolean
to true
for example, as there might be a specific part of our application, where a User
has to be in an active state? There might be situations where we need to transform all properties of a specific type to a different type. One example would be converting a boolean flag to a string after validating some object and wanting to display error messages.
type User = {
points: number;
name: string;
active: boolean;
};
/**
type ActiveUser = {
points: number;
name: string;
active: true;
}
*/
type ActiveUser = ConvertTo<User, boolean, true>;
Our ConvertTo
helper should be able to receive some type T
and the type we want to convert from as well as the type we want to convert to.
type ConvertTo<Type, From, To> = {
[Key in keyof Type]: Required<Type>[Key] extends From ? To : Type[Key];
};
The ConvertTo
helper maps over the provided type and then checks if a property extends the type we want to transform, either keeping the original type or applying the type we want to transform to. One important note here, is that we need to make the lookup strict via Required<Type>[Key]
, otherwise any optional properties will be skipped in the mapping function.
Next, we will write some helper functions, which we can use a basic building blocks for writing more specific helpers later on. One basic functionality is to be able to filter based on a defined type. The general use case might not be interesting from a user land perspective, as there might not be too many situations where want to remove all properties that are not of a specific type. The FilterByType
function only returns keys that match the type we want to filter by.
type FilterByType<T, U> = {
[Key in keyof Required<T>]: Required<T>[Key] extends U ? Key : never;
}[keyof T];
Using the FilterByType
:
type User = {
id: number;
name: string;
points: number;
active: boolean;
};
type FilteredUserKeys = FilterByType<User, number>;
// type FilteredUserKeys = "id" | "points"
From the above example we can see that this helper functions filters all the keys that have the type number
for the provided User
shape. If we recall, Omit
and Pick
both expect a type and the keys to be applied. By combing Omit
or Pick
with our previously defined FilterByType
helper, we can create two new useful helpers: IncludeByType
and ExcludeByType
. The next example should help us understand how this works.
type ExcludeByType<T, U> = Omit<T, FilterByType<T, U>>;
type IncludeByType<T, U> = Pick<T, FilterByType<T, U>>;
The two generic helpers ExcludeByType
and IncludeByType
expect a type T
and the type we want to filter by.
type User = {
id: number;
name: string;
points: number;
active: boolean;
};
/**
type UserWithoutTypeNumber = {
name: string;
active: boolean;
}
*/
type UserWithoutTypeNumber = ExcludeByType<User, number>;
/**
type UserWithOnlyTypeNumber = {
id: number;
points: number;
}
*/
type UserWithOnlyTypeNumber = IncludeByType<User, number>;
The above examples are equivalent to writing:
type UserWithoutTypeNumber = Omit<User, 'name' | 'string'>;
type UserWithOnlyTypeNumber = Pick<User, 'id' | 'number'>;
So IncludeByType
and ExcludeByType
can be used as further building blocks to construct helpers that take care of transforming types to readonly
, optional
or required
. Now that we have all the low level helper functions in place we can combine these helpers with the TypeScript provided helpers. To make a specified properties read-only by type, we can use Readonly
and combine it with the type T
we want to transform.
type ReadOnlyByType<T, U> = T & Readonly<IncludeByType<T, U>>;
The above code could also be rewritten, to make what is happening more clear, like so:
type ReadOnlyByType<Type, FilterType> = Type & Readonly<IncludeByType<Type, FilterType>>;
Using ReadOnlyByType
would make a defined type readonly
by filtering all properties that extend the filter-by type, making them readonly
and then combining them with original structure.
type User = {
id: number;
name: string;
points: number;
active: boolean;
};
/**
type UserNumberReadOnly = {
readonly id: number;
name: string;
readonly points: number;
active: boolean;
};
*/
type UserNumberReadOnly = ReadOnlyByType<User, number>;
This enables us to prevent id
and points
to be overridden in this case.
const user: UserNumberReadOnly = {
id: 1,
name: "test",
points: 20,
active: true
};
user.id = 3;
// Cannot assign to 'id' because it is a read-only property.
user.points = 30;
// Cannot assign to 'points' because it is a read-only property.
Finally, we might need the capability to transform some type to optional or required for a given shape T
. Again, by using the TypeScript provided Partial
we can now provide two helper functions that can either set a specific type to optional and all others to required or vice versa. Using our previously defined ExcludeByType
or IncludeByType
, we can make a specified type U
to optional or required.
type OptionalByType<T, U> = Partial<IncludeByType<T, U>> & ExcludeByType<T, U>;
type RequiredByType<T, U> = Partial<ExcludeByType<T, U>> & IncludeByType<T, U>;
This enables us now to transform our User
type:
/**
type UserNumbersOptional = {
id?: number;
name: string;
points?: number;
active: boolean;
};
*/
type UserNumbersOptional = OptionalByType<User, number>;
/**
type UserOnlyNumbersRequired = {
id: number;
name?: string;
points: number;
active?: boolean;
};
*/
type UserNumbersRequired = RequiredByType<User, number>;
There might be one last case, where we would like to only convert all properties of defined type to required, but not change the optional/required for any other properties. In this case we can use the TypeScript helper Required
.
type OnlyMakeTypeRequired<T, U> = ExcludeByType<T, U> &
Required<IncludeByType<T, U>>;
OnlyMakeTypeRequired
enables us to only make these properties required that fit a defined type U
. Our User
shape might contain optional and required fields, but only the types that match U
will be transformed to required, leaving all other properties as is. Check the following example for better understanding.
type User = {
id: number;
points?: number;
name: string;
active?: boolean;
};
/**
type User = {
id: number;
name: string;
points: number;
active?: boolean;
}
*/
type RequiredByTypeOnlyUser = OnlyMakeTypeRequired<User, number>;
const user: RequiredByTypeOnlyUser = {
id: 1,
name: "Test"
};
// Error! points is required now!
// Property 'points' is missing in type '{ id: number; name: string; }' but required in type 'Required<Pick<User, "id" | "points">>'
As we have seen in the previous examples, we can build helper functions when needed for specific situations by leverage type level programming. If you're interested to learn more about type level programming, you can study the TypeScript provided helpers like Partial
, Pick
or Readonly
, furthermore the first two posts on type level programming (see links below) in the "Notes on TypeScript" series might be helpful as well.
Links
If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif
Top comments (0)