Overriding Specific Property Types Using Mapped and Conditional Types in TypeScript
5 min read

Overriding Specific Property Types Using Mapped and Conditional Types in TypeScript

How can you easily transform types to change specific properties without hardcoding tons of property key names?
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 type T and assign them to the type alias Property

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 type T[Property] extends the type ObjectId
Then substitute the property type as string
Else use the original T[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):

import { ObjectId } from 'mongodb';

type MapObjectIdToString<PropType> = 
  PropType extends ObjectId ? string : SourceType;

type SerializedMongoEntity<TMongoEntity> = {
  [PropertyKey in keyof TMongoEntity]:
    MapObjectIdToString<TMongoEntity[PropertyKey]>;
}
A bit more verbose but clearer?

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.

Enjoying these posts? Subscribe for more