A common case in applications is to work with types where certain properties are nullable. However, it is also common to need derived types of these same types which are stricter around nullablity of properties.
Let’s imagine a note taking app: We have a Notebook
type where id
is nullable because unsaved notebooks haven’t been assigned an id
yet. However after saving it, we know for a fact that a Notebook
instance will have an id
and ideally we shouldn’t have to say notebook.id!
in the rest of the application.
So, in this post we will explore a generic way to derive types like SavedNotebook
where certain properties are conditionally mapped to non-optional.
TS comes with a NonNullable
type, which essentially strips out null
and undefined
from any type.
1 2 3 4 |
type T1 = string | null | undefined; type T2 = NonNullable<T1>; // string |
We also have mapped types that allow us to map over a type and programmatically derive the type for each key.
So the following makes all values non-nullable:
1 2 3 4 5 |
export type SavedNotebook = { [T in keyof Notebook]: NonNullable<Notebook[T]> } |
Now, we can use conditional types to make only certain values non-nullable:
1 2 3 4 5 6 7 |
export type SavedNotebook = { [T in keyof Notebook]: T extends "id" ? NonNullable<Notebook[T]> : Notebook[T] } |
Or, a more generic implementation:
1 2 3 4 5 6 7 8 9 |
export type MandateProps<T extends {}, K extends keyof T> = { [TK in keyof T]: TK extends K ? NonNullable<T[TK]> : T[TK] } type SavedNotebook = MandateProps<Notebook, "id">; |
Our SavedNotebook
type will effectively be:
1 2 3 4 5 6 7 |
{ id: number; name: string | null; description: string | null; } |
Looks good! Are we done then ? It turns out, not quite.
Let’s tweak our Notebook
interface a bit:
1 2 3 4 5 6 7 |
interface Notebook { id?: number; name?: string; description?: string; } |
We made the properties optional. Our SavedNotebook
type will no longer evaluate to what we expect:
1 2 3 4 5 6 7 |
type SavedNotebook = { id?: number | undefined; name?: string | undefined; description?: string | undefined; } |
What happened here ? Wasn’t our NonNullable
type supposed to take care of both null
and undefined
?
Well, it did, but it turns out typescript treats the following as different cases:
- Whether a key may (or may not) be present in an object type
- Whether the corresponding value may be
undefined
.
Mapped types preserve the former, and so our MandateProps
is not able to remove the undefined
from Notebook["id"]
.
So, what should be do if we do want to make that key mandatory as well ?
Thankfully typescript allows us to suffix our key with -?
while mapping over types to remove optionality.
But doing that conditionally requires another workaround:
1 2 3 4 5 |
export type MandateProps<T extends {}, K extends keyof T> = Omit<T, K> & { [MK in K]-?: NonNullable<T[MK]> } |
Omit<T, K>
retains all the optional keys that we don’t care about and gets rid of the keys that are assignable to K
. Then we create another type for the keys which match K
, and while mapping over them we make the keys not optional. Intersection of the both gives us a type where all keys that are assignable to K
are mandatory.
1 2 3 4 5 6 7 8 9 |
type SavedNotebook = MandateProps<Notebook, "id">; // ^ Is equivalent to: type SavedNotebook = Pick<Notebook, "name" | "description"> & { id: number; } |
This is pretty much what we want. Omit
is internally implemented using Pick
and typescript is being lazy about Pick
.
1 2 3 |
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>; |
1 2 3 |
type ID = SavedNotebook["id"]; // number |
Voila.