Type-Safe Object Merging (TypeScript 2.8 Edition)

There are times when you want to merge two generic types in TypeScript, and type inference just isn’t doing it for you. Object.assign’s typing isn’t as precise as it could be, and spreading generics still doesn’t work. I’ve found a way to implement typing when merging objects using some of the new features in TypeScript 2.8.

A Sample Problem

Let’s start by defining some types we can use as examples, along with an instance of each:


  type A = { value: { a: number } };
  type B = { value: { b: number } };

  const aObj: A = { value: { a: 4 } };
  const bObj: B = { value: { b: 5 } };

The task I want to accomplish is to create a generic function capable of merging aObj into bObj. By “merge,” I mean that I want to replicate the functionality of Object.assign with a finite number of arguments. Essentially, I want a function that has a signature like this:


  const merge = <T, U>(t: T, u: U) => // merge t and u

The Problems

A first attempt could look something like this:


  const merge = <T extends object, U extends object>(t: T, u: U) => ({
    ...t,
    ...u
  });

Unfortunately, at the time of this writing, TypeScript can’t handle this; you’ll get a Spread types may only be created from object types error. There’s currently an issue for it.

A second attempt could look like this:


  const merge = <T, U>(t: T, u: U) => Object.assign({}, t, u);
  const output = merge(bObj, aObj);

This is so close. You’ll see the return type of the function is U & T. Makes sense, right? There’s actually a subtle catch: The type of output.value is now { a: number } & { b: number }, when it should only be { a: number }. So TypeScript thinks output.value.b is a number, when it does not actually exist.

I think we can do better.

A Roadmap

First, let’s break down Object.assign’s output when called like this: Object.assign({}, foo, bar). The output will have everything from bar, and anything from foo that doesn’t share a key with something in foo. We can make a few buckets that will be helpful in thinking about the key-value pairs from both objects.

  • Keys and their corresponding values where the key exists in foo, but not bar
  • Keys and their corresponding values where the key exists in bar, but not foo
  • Keys and their corresponding values where the key exists in both foo and bar. In this case, key-value pairs from bar win out, unless their value is undefined.

These groups will be the basis for building our function’s types. (If I missed anything, let me know in the comments!)

We’ll get to defining a few of these types in a minute, but in the meantime, here’s what I want merge to look like:


  export const merge = <T extends object, U extends object>(t: T, u: U) => ({
    ...(t as object),
    ...(u as object)
  } as MinusKeys<T, U> & MinusKeys<U, T> & MergedProperties<U, T>);

Note that we cast the output to correspond to the intersection of the three bullet points listed above.

MinusKeys

Credit goes to my coworkers for figuring this one out.

To handle the first two situations (keys and their corresponding values where the key exists in foo, but not bar), it’s helpful to define a MinusKeys type:


  export type MinusKeys<T, U> = Pick<T, Exclude<keyof T, keyof U>>

This creates a type that has everything from T that isn’t in U. Exclude is a new type in TypeScript 2.8 in which everything from the second argument is removed from the first. So if the keys in U are “a” and “b,” and the keys in T are “a” and “c,” it evaluates to “c.” We then Pick the resulting keys from our first type T.

MergedProperties

We want this to have keys for everything in both types T and U. When the property is not optional in T, we take the type from T. When it is optional, it’ll be either from T or U.


  export type Defined<T> = T extends undefined ? never : T;
  export type MergedProperties = { [K in keyof T & keyof U]: undefined extends T[K]? Defined<T[K] | U[K]> : T[K]};

This uses the newly added extends keyword to pick between values being defined by either T or U. if a property is optional, then undefined extends T[K] will be true, so the output value could come from T or U. Otherwise, the the value’s type is just determined by T.

Defined is just a helper to keep unwanted undefineds out of our end result.

Wrapping It Up

So that’s it for the function definition. Going back to the starting example, we can now call merge(bObj, aObj), and the type system will allow us to access value.a, but it will yell at us if we try to access value.b.

Success!