Making TypeScript's Partial type work for nested objects

TL;DR?

If you’re just here to copy the type definition, jump to the conclusion and copy away. ✊

Without further ado…

Meet TypeScript’s Partial type

There are functions in which you do not want to pass or accept the full object, but rather a subset of its properties. An example would be a database update function.

Consider this fictional User type:

interface User {
    id: number;
    firstName: string;
    lastName: string;
}

We could write the update function like this:

function updateUser(data: User) {
    // Update here.
}

But this will require us to always pass a full User object. We would rather be able to just pass the attributes of the user that changed, like this:

updateUser({ lastName: "Johnson" });

However, TypeScript won’t accept this, because this is not a User, it’s an arbitrary object with a lastName property! Of course we can escape using the any type, but that won’t give us any of TypeScript’s benefits.

Luckily, TypeScript provides so-called Utility Types, one of which is the Partial type.

We can use it to fix our updateUser function:

function updateUser(data: Partial<User>) {
    // Update here.
}

Awesome! This works: the function will accept an object consisting of some or all of User’s properties.

πŸ“– Read the TypeScript documentation on the Partial type

Complicating things with nested objects

All right, now let’s turn up the heat on poor Partial.

Let’s say our User type contains nested objects:

interface User {
    id: number;
    firstName: string;
    lastName: string;
    address: {
        street: string;
        zipcode: string;
        city: string;
    };
}

Our updateUser function will still work, and you can definitely omit the address property, but what you can’t do, is pass a partial address object. This will fail:

updateUser({
    address: {
        city: "Amsterdam",
    },
});

TypeScript will yell at you:

Type '{ city: string; }' is missing the following properties
  from type '{ street: string; zipcode: string; city: string; }':
    street, zipcode

From this we can conclude that Partial allows you to omit any property from the original interface, but you cannot change the shape of the values. Any nested object should be in the original shape, and thus contain all of its properties.

We can fix this by re-creating the Partial type.

Let’s take a look at the Partial type’s definition:

type Partial<T> = { [P in keyof T]?: T[P] };

What’s going on here?

keyof is a so-called type operator. It produces a union type of all the keys of an object. For example:

type Point = { x: number; y: number };
type P = keyof Point;

P in this example is equivalent to type P = "x" | "y".

πŸ“– Read the TypeScript manual on the keyof type operator

Another relevant keyword in the Partial definition is in. It can be used to define a Mapped Type.

type Point = { x: number; y: number };
type P = {
    [Property in keyof Point]: {
        value: number;
        units: "px" | "em" | "rem";
    };
};

In this example P is a type that supports objects looking like this:

const foo: P = {
    x: { value: 42, units: "px" },
    y: { value: 999, units: "rem" },
};

πŸ“– Read the TypeScript manual on Mapped Types

Last but not least, the little question mark in the Partial type definition might have escaped your attention but is actually the most important part of the definition. It makes properties optional, which is the raison d’Γͺtre of the Partial type!

Now that we have a solid understanding of the definition, we can see clearly that nothing in this type would allow partial nested objects. Let’s come up with our own type to fix this. We will call it Subset since Partial is taken.

type Subset<K> = {
    [attr in keyof K]?: K[attr] extends object ? Subset<K[attr]> : K[attr];
};

This looks a little daunting, but look closely, and you’ll see that most of it is equivalent to the original Partial type.

[attr in keyof K]?:

This part again means: create a type containing all properties of K, and make all of them optional.

The definition of the value is a little more convoluted:

K[attr] extends object ? Subset<K[attr]> : K[attr];

This value takes the form of a JavaScript ternary operator. TypeScript allows dynamic structures like this thanks to a feature called Conditional Types. Conditional Types allow you to inspect a given parameter and create a logic branch in the definition of your type.

Here’s an example to illustrate this behavior:

interface Point {
    x: number;
    y: number;
}
type Point3D<P> = P extends Point
    ? {
          [key in keyof P]: P[key];
      } & {
          z: number;
      }
    : never;

When given a Point, this Point3D generic type will take all properties from the given type P, and add a z property. But given any other type, it’s defined as never.

πŸ“– Read the TypeScript manual on Conditional Types

With this, we can take another look at the property values of our Subset type:

[attr in keyof K]?: K[attr] extends object ? Subset<K[attr]> : K[attr];

This says: for every property of type K, see whether its value extends object, and if so, make it also a Subset, otherwise just copy its definition from the original.

And with that, we can update our updateUser function to be:

function updateUser(data: Subset<User>) {
    // Update here.
}

Now the function will allow any partial User object, and even respect partial nested objects. Great!

Screenshot showing a code editor helpfully offering suggestions for available attributes.

Now my editor can be extra helpful!

In conclusion

type Subset<K> = {
    [attr in keyof K]?: K[attr] extends object ? Subset<K[attr]> : K[attr];
};

This type probably looks very complicated to beginning TypeScript developers, but when broken down, the parts refer to well-documented behaviors and concepts.

TypeScript is an amazingly expressive language, which enables you to come up with highly dynamic interfaces that perfectly adhere to the rules of your application.

The profit, in the end, is in your editor, which will tell you exactly what’s expected and help you do the right thing.


We welcome your feedback

We enjoy compliments, but you can totally shout at us for doing it wrong on our Twitter account πŸ‘‹