Overriding Specific Property Types Using Mapped and Conditional Types in TypeScript
Let's say you have the following DbUser
type:
interface DbUser {
id: ObjectId;
employer_id: ObjectId;
name: string;
age: number;
}
What would you do if you wanted to create a new type UiUser
that was the same but with the ObjectId
type annotations changed to string
?
Overriding specific property type annotations of another type
Here is what my proposed solution looks like:
interface DbUser {
id: ObjectId;
employer_id: ObjectId;
name: string;
age: number;
}
type MapObjectIdToString<PropType> =
PropType extends ObjectId ? string : PropType;
type MapDbObject<T> = {
[PropertyKey in keyof T]:
MapObjectIdToString<T[PropertyKey]>;
}
type UiUser = MapDbObject<DbUser>;
The MapDbObject<T>
helper will take the type T
and convert any of its properties with a type annotation ObjectId
and replace it with string
. This scales to handle any number of properties and only requires a single type alias per usage.
This question was inspired by a colleague at work asking about sharing types between a TypeScript app with a MongoDB backend.
How did we arrive at this approach and how does work exactly?
Ways That Don't Work
There were a few ways we tried that didn't work.
Overriding an interface with extends
Could you "override" a property by extending the interface?
interface DbUser {
id: ObjectId;
employer_id: ObjectId;
name: string;
age: number;
}
interface UiUser extends DbUser {
id: string;
employer_id: string;
}
Unfortunately, interfaces cannot redefine the type of an inherited (or ambient) property:
Interface 'UiUser' incorrectly extends interface 'DbUser'.
Types of property 'id' are incompatible.
Type 'string' is not assignable to type 'ObjectId'.(2430)
Overriding interfaces using intersection
What about trying to intersect two interfaces? Would putting the second type last "override" the first?
interface DbUser {
id: ObjectId;
employer_id: ObjectId;
name: string;
age: number;
}
interface IdAsString {
id: string;
employer_id: string;
}
type UiUser = DbUser & IdAsString;
var foo: UiUser = { id: "abc" }
It almost works except that id
is unioned so the resulting type is string | ObjectId
🤦♂️
However, we are getting closer. We can use type aliases to redefine or re-map property types in pretty powerful ways.
Overriding properties using the Omit helper
Initially my colleague tried a combination of using the Omit helper and intersection types:
type UiUser = Omit<DbUser, 'id' | 'employer_id'> & {
id: string;
employer_id: string;
}
For simple cases, this works fine. The problem is that this won't scale with multiple ObjectId
properties since they'd have to enumerate them in the Omit
helper and intersecting object. It would create a maintenance nightmare.
Since we want to scale we can introduce some more language features of TypeScript. 🙌
Using mapped and conditional typing to transform types
I love TypeScript because there are features in the language that let us achieve really granular business rules like this.
The two features I leaned on for this were Mapped types and Conditional types.
Mapped Types
This is a "mapped type" in TypeScript. It follows a pattern of using a "property key" index expression:
type MapDbObject<T> = {
[<PropertyKey> in keyof <SourceType>]: <ReturnTypeExpression>
}
An indexing expression is similar to what indexing looks like for JavaScript objects like user["name"]
. We'd normally write that as user.name
but sometimes we have a variable that represents the property to retrieve like user[nameField]
.
In a TypeScript mapped type, the expression inside the brackets tells the compiler two things: what to label the matching PropertyKey
and what to assign the value of the match (in keyof T
). The in
keyword essentially makes this an iterable expression (like the JavaScript expression "name" in user
). keyOf
is an operator that can be used in any type expression.
So to translate the left-hand bracket expression:
Match all property key names in the typeT
and assign them to the type aliasProperty
Imagine substituting the type and seeing the list of properties enumerated by the compiler with the expression keyOf User
:
id
employer_id
name
age
If you imagine the intermediate steps, this is what is substituted in that left hand expression:
type MapDbObject<T> = {
[Property in keyof T]: <ReturnTypeExpression>
}
type UiUser = MapDbObject<User>;
// Intermediate step for left-hand substitution:
UiUser {
id: <type?>;
employer_id: <type?>;
name: <type?>;
age: <type?>;
}
I hope this sort of makes sense. Next, the right-hand side of the map expression will tell the compiler what <type?>
is supposed to be.
This means if we provided T[Property]
as the right-hand expression we would be redefining the DbUser
type verbatim:
// if passing the same exact type (Property)
type MapDbObject<T> = {
[Property in keyof T]: T[Property]
}
type UiUser = MapDbObject<DbUser>;
// it would map to the equivalent type
UiUser {
[id]: DbUser["id"];
[employer_id]: DbUser["employer_id"];
[name]: DbUser["name"];
[age]: DbUser["age"];
}
UiUser {
id: ObjectId;
employer_id: ObjectId;
name: string;
age: number;
}
The T[Property]
expression in TypeScript returns the type of that property e.g. DbUser["id"]
returns the (type) value ObjectId
.
This is close but obviously not what we want. At this point, we want to say "return string
if T[Property]
is ObjectId
".
To accomplish that, we can use conditional typing.
Conditional Typing
Let's add a conditional type expression on the right-hand side of the mapping expression:
type MapDbObject<T> = {
[Property in keyof T]:
T[Property] extends ObjectId ? string : T[Property];
}
I hope this feels a bit familar and maybe you can intuit what this does.
It says:
If the property typeT[Property]
extends the typeObjectId
Then substitute the property type asstring
Else use the originalT[Property]
type
So once again, the intermediate compiler steps might look like this:
type MapDbObject<T> = {
[Property in keyof T]:
T[Property] extends ObjectId ? string : T[Property];
}
type UiUser = MapDbObject<DbUser>;
// intermediate steps, substituting T[Property]
UiUser {
id: ObjectId extends ObjectId ? string : ObjectId;
employer_id: ObjectId extends ObjectId ? string : ObjectId;
name: string extends ObjectId ? string : string;
age: number extends ObjectId ? string : number;
}
UiUser {
id: string;
employer_id: string;
name: string;
age: number;
}
Et voila! We have now overridden just the ObjectId
-based properties id
and employee_id
without affecting name
or age
.
Refactoring for readability
I love TypeScript but it gets a bad rap for readability. I understand this but there are ways to make type expressions more readable and it's the same as in regular programming: extract expressions to named things.
Since the right-hand expression is a bit unwieldy and not very descriptive I opted to extract it into its own named alias to make it clearer and simplify the usage:
type MapObjectIdToString<T> = T extends ObjectId ? string : T;
type MapDbObject<T> = {
[Property in keyof T]: MapObjectIdToString<T[Property]>;
}
The MapObjectIdToString
takes the type to inspect T
and performs the same conditional expression. At the consumption site, we are passing the type of the property T[Property]
which we were doing previously but now we only need to provide that once.
At this point we could be really verbose and clear and rename the type aliases to be specific to the domain they deal with (i.e. MongoDB entities in the real-life example):
I will let you determine how verbose you'd prefer to be! I like things to be really clear about their intended usage 😁
Let the types flow
Using generic, mapped, and conditional types are definitely more complicated but I hope you can see how the types flow now through that generic substitution and why these features make TypeScript incredibly powerful. Using them together allows you to be more specific while leaving the rest of the object alone.
You can find a working example of this snippet on the TypeScript Playground.
I am calling this a proposed solution because TypeScript always surprises me with its power. Do you have another way to achieve the same result? I'd love to hear it.
Appendix: How do you handle nested types?
As a follow-up question, Saravanan asked how to handle nested objects. The answer lies in recursive types.
Here's a link to the answer!