Introduction
These notes should help in better understanding TypeScript
and might be helpful when needing to lookup up how leverage TypeScript in a specific situation. All examples are based on TypeScript 3.2.
MappedTypes
In this part of the "Notes on TypeScript" series we want to level up on our type level programming knowledge in TypeScript. To get a better fundamental understanding of the topic, we will revisit some topics we discussed in "Type Level Programming Part 1" and see in more depth how these types are implemented. To be more specific let's gain a better understanding of Mapped Types.
In a previous post we implemented a Partial, a Required and a ReadOnly type.
type User = {
id: number;
name: string;
};
type MakeReadOnly<Type> = {readonly [key in keyof Type ]: Type[key]};
// Test MakeReadOnly
type ReadOnlyUser = MakeReadOnly<User>;
/*
type ReadOnlyUser = {
readonly id: number;
readonly name: string;
}
*/
We implemented these type without discussing in more depth, the actual underlying mechanism, which we will do now. Taking a look at our MakeReadOnly
, we notice that we can map over the property types enabling to create a new type. In the specific example above, we mapped over all the property types and transformed them to readonly.
Let's take a look at another example we previously implemented.
type MakePick<Type, Keys extends keyof Type> = { [Key in Keys]: Type[Key] };
Our MakePick
, which reflects the provided Pick
, goes through all the keys, that extend the provided Type
and returns a new type.
type User = {
id: number;
name: string;
points: number;
};
type TestMakePick = MakePick<User, "id" | "name">;
/*
type TestMakePick = {
id: number;
name: string;
};
*/
This is what is actually happening when we try to select the id and name from User
:
type TestMakePick = { [Key in "id" | "name"]: User[Key] };
/*
type TestMakePick = {
id: number;
name: string;
};
*/
Using lookup types, which we will explore in more depth in the next section, we can rewrite the above example to the following:
type TestMakePick = {
id: User["id"],
name: User["name"]
};
/*
type TestMakePick = {
id: number;
name: string;
};
*/
The above transformations, should help us get a better idea of how mapped types work.
Lookup Types
To build more advanced types, let's look at two interesting TypeScript features Lookup types and keyof.
type UserKeyTypes = User["id" | "name" | "points"];
/*
type UserKeyTypes = number | string;
*/
By using keyof
, we can rewrite the above type to the following:
type UserKeyTypes = User[keyof User];
/*
type UserKeyTypes = number | string;
*/
keyof
can help us avoid having to manually define all the keys, rather letting TypeScript provide the keys, which helps in avoiding having to keep updating these types once they change.
type UserKeys = keyof User;
/*
type UserKeys = "id" | "name" | "string";
*/
Lookup types, also referred to as indexed access types enable us to access the types for provided keys. Similar to how we can access the values of object properties, but for types.
type UserNameType = User["name"];
/*
type UserNameType = string;
*/
We can also look up a number of keys as seen in the first example we wrote in this section:
type UserKeyTypes = User["id" | "name" | "points"];
/*
type UserKeyTypes = number | string;
*/
This is an interesting feature and enables developers to provide more explicit type handling when dealing with object properties. Let's replicate a simpler (sans currying) implementation of the prop
and assoc
functions from the Ramda library.
function prop(obj, key) {
return obj[key]
}
One way to type the prop
function would be to provide an object type and define the key as a string. But TypeScript will not be able to infer the return type.
We need to be more explicit about the key type, which we can achieve by guaranteeing that the key type extends the provided object key types via defining: Key extends keyof Type
.
function prop<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
const user: User = {
id: 1,
name: "Test User",
points: 0
};
const userName = prop(user, "name"); // const userName : string;
The interesting aspect here, is that TypeScript can now infer the return type of the return value:
return obj[key]; // => Type[Key]
Another benefit we get by leveraging lookup types and keyof is that we can ensure that only existing property keys can be passed to prop
.
// The following will result in TypeScript complaining:
const userName = prop(user, "status");
// Argument of type '"status"' is not assignable to parameter of type '"id" | "name" | "points"'
So trying to access an non existent property will cause TypeScript to complain that the provided type is not assignable. To gain more understanding and validate what we have learned so far, let's implement assoc
.
function assoc<Type, Key extends keyof Type>(
obj: Type,
key: Key,
value: Type[Key]
) {
return { ...obj, [key]: value };
}
assoc
looks similar to the prop
function, but as we are also providing a value this time, we can use lookup types to define the expected value type via Type[Key]
.
const updatedUserName = assoc(user, "name", "User Test A");
const updatedUserPoints = assoc(user, "points", 0);
// The following will examples result in TypeScript complaining:
const updatedUserPoint = assoc(user, "point", 0);
// Argument of type '"point"' is not assignable to parameter of type '"id" | "name" | "points"'
const updatedUserPointsAsString = assoc(user, "point", "0");
// Argument of type '"0"' is not assignable to parameter of type 'number'
Via leveraging lookup types we can guarantee that the expected update value has the correct type.
Mapped Types and Lookup Types
Finally, let's revisit a more advanced example we built when working with conditional types.
type RemoveUndefinable<Type> = {
[Key in keyof Type]: undefined extends Type[Key] ? never : Key
}[keyof Type];
type RemoveNullableProperties<Type> = {
[Key in RemoveUndefinable<Type>]: Type[Key]
};
type TestRemoveNullableProperties = RemoveNullableProperties<{
id: number;
name: string;
property?: string;
}>;
/*
type TestRemoveNullableProperties = {
id: number;
name: string;
};
*/
The RemoveNullableProperties
conditional type expects a type and returns a new type only containing non nullable property types. It's a good idea to break the existing implementation into multiple parts as we have a better understanding of mapped and lookup types now.
type RemoveUndefinable<Type> = {
[Key in keyof Type]: undefined extends Type[Key] ? never : Key
}[keyof Type];
Let's start with RemoveUndefinable
first:
type RemoveUndefinableKeys<Type> = {
[Key in keyof Type]: undefined extends Type[Key] ? never : Key
};
type TestRemoveUndefinableKeys = RemoveUndefinable<{
id: number;
name: string;
property?: string;
}>;
/*
type TestRemoveUndefinableKeys = {
id: "id";
name: "name";
property?: undefined;
}
*/
We get a new type containing the key names or undefined depending wether the type extends undefined or not. If you recall, we can lookup multiple type like so: {a: number; b: string; c: number[]}["a" | "b"]
, which means we can use a lookup to extract all the relevant keys for the provided types in the next step.
type RemoveUndefinableKeys<Type> = {
[Key in keyof Type]: undefined extends Type[Key] ? never : Key
};
type RemoveUndefinable<Type> = RemoveUndefinableKeys<Type>[keyof Type];
type TestRemoveUndefinable = RemoveUndefinable<{
id: number;
name: string;
property?: string;
}>;
/*
type TestRemoveUndefinable = "id" | "name" | undefined;
*/
RemoveUndefinable
returns all the mapped key names, so our next step is remove any non existent keys from the provided type.
type RemoveUndefinableKeys<Type> = {
[Key in keyof Type]: undefined extends Type[Key] ? never : Key
};
type RemoveUndefinable<Type> = RemoveUndefinableKeys<Type>[keyof Type]>;
type RemoveNullableProperties<Type> = {
[Key in RemoveUndefinable<Type>]: Type[Key]
};
type TestRemoveNullableProperties = RemoveNullableProperties<{
id: number;
name: string;
property?: string;
}>;
/*
type TestRemoveNullableProperties = {
id: number;
name: string;
};
*/
We can exchange the RemoveNullableProperties
with the TypeScript provided Pick
type, as the implementation of Pick
is similar to the RemoveNullableProperties
implementation:
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
Exchanging RemoveNullableProperties
with Pick
leaves us with the following implementation:
type RemoveUndefinableKeys<Type> = {
[Key in keyof Type]: undefined extends Type[Key] ? never : Key
};
type RemoveNullableProperties<Type> = Pick<
Type,
RemoveUndefinableKeys<Type>[keyof Type]
>;
type TestRemoveNullableProperties = RemoveNullableProperties<{
id: number;
name: string;
property?: string;
}>;
/*
type TestRemoveNullableProperties = {
id: number;
name: string;
};
*/
We should have a good understanding of mapped types, lookup types and keyof and how to leverage them when working with TypeScript. The knowledge gained in this write-up should help us to build more advanced type level programming examples in the upcoming "Notes on TypeScript", which will be focusing on more advanced type level programming.
Links
TypeScript 2.1: keyof and Lookup Types
Notes on TypeScript: Type Level Programming Part 1
Notes on TypeScript: Conditional Types
If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif
Top comments (1)
Incredible series, thanks so much for sharing this.
Would greatly appreciate if you could clarify the following:
In this example
when we pass this portion of a
Type
which is the same as
it evaluates to truthy branch of
extends
, andI don't quite understand how does it form
undefined
?when supposedly, it should be getting
never
intrue
branch.Another even more confusing thing is if we explicitly
pass
skipping
?
marking operator,we will get
Thank you.