Unionize and Objectify: A Trick for Applying Conditional Types to Objects

Conditional types are the most powerful weapon TypeScript gives us for mapping between types. They do their best work on union types, so sometimes it pays to apply slightly counterintuitive transformations to get a union of types, rather than an object. This post presents Unionize and Objectify, two tools I've found extremely helpful for constructing complex mappings between object types.

Sometimes the easiest way to get from A to B isn't the direct path. If you have great tools to solve problems in another domain, then mapping your problem onto that domain might just do the trick:

Going from A to B is hard, but f(A) to f(B) is easy

Examples of this are everywhere in math, science and software. It's not obvious that assigning cartesian coordinates to points in a geometry problem will help solve it, but it works because we have great tools for solving algebra problems. It's not obvious that writing computer programs as a series of matrix operations will help you recognize handwritten digits, but it does because we have great tools like GPUs, backprop and gradient descent for working with the matrix formulation.

So what does this have to do with TypeScript? In TypeScript our most powerful tool is conditional types. This is because they have two unique abilities:

  1. They distribute over unions.
  2. They enable you to use the infer keyword.

So to the extent that we can map TypeScript problems into the domain of unions and conditional types, we'll find them easier to solve.

Jsonify with methods

As an example, consider a previous post where we looked at the type of a variable before and after JSON serialization:

type Jsonify<T> = T extends {toJSON(): infer U}
? U
: T extends object
? {
[k in keyof T]: Jsonify<T[k]>;
}
: T;

function jsonRoundTrip<T>(x: T): Jsonify<T> {
return JSON.parse(JSON.stringify(x));
}

const v = jsonRoundTrip({
name: 'Bobby',
age: 12,
greet() { return 'Hi!'; },
});
// type is {
// name: string;
// age: number;
// greet: {};
// }

To get a more accurate type, we'll need to filter out the properties with function values. But how do you do that? More generally, how do you filter out properties from an object type that are assignable to some other type?

type OmitProperties<T extends object, V> = ???;

OmitProperties

One idea is to use mapped types and conditional types together:

type OmitProperties<T extends object, V> = {
[k in keyof T]: T[k] extends V ? never : T[k];
};

If the value type for a key (T[k]) extends V, then we change it to a never type. Otherwise we leave it as-is. Here's how that shakes out:

interface Person {
name: string;
age: number;
greet: () => string;
}
type NonFunctionalPeople = OmitProperties<Person, Function>;
// type is {
// name: string;
// age: number;
// greet: never;
// }

This is close to what we want, but it's not exactly right. The getId key is still in the result type. It has a never value type, sure, but it's distracting and it will still show up in keyof expressions:

type K = keyof NonFunctionalPeople;  // type is "name" | "age" | "greet"

Depending on the situation, this might be a disaster. For example, if you have a function to index a list based on a field, greet will be allowed because it's in keyof T:

declare let people: NonFunctionalPeople[];
function indexByField<T>(
vals: T[], field: keyof OmitProperties<T, Function>
) { /* ... */ }
const index = indexByField(people, 'greet'); // OK, should be an error

So what to do? We used conditional types here, but we didn't apply them to a union type, which is where they do their best work. To make a better OmitProperties, we need to map from the domain of object types to the domain of union types.

Unionize and Objectify

I learned a trick for this from Titian Cernicova-Dragomir on Stack Overflow. You map from an object type to a union type of {k, v} pairs. Let's call this transformation Unionize:

type Unionize<T extends object> = {
[k in keyof T]: {k: k; v: T[k]}
}[keyof T];

type PersonUnion = Unionize<Person>;
// type is { k: "name"; v: string; } |
// { k: "age"; v: number; } |
// { k: "greet"; v: () => string; }

We've used a mapped type [k in keyof T] and an index operation [keyof T] to transform the object type into a union of types with k / v pairs. The key is a string literal type and the value is the value type.

You can put the object back together again using the inverse operation. Let's call that Objectify:

type KVPair = {k: PropertyKey; v: unknown}
type Objectify<T extends KVPair> = {
[k in T['k']]: Extract<T, {k: k}>['v']
};

This one is a little tricker. PropertyKey is an alias for anything that can be used as a property key in TypeScript: string | number | symbol. The T['k'] extracts all the key types:

type K = PersonUnion['k'];  // type is "age" | "name" | "greet"

Then we use Extract to find the k/v pair for each key and pull out the corresponding value:

type KV = Extract<PersonUnion, {k: 'age'}>;  // type is {k: "age"; v: number; }
type V = Extract<PersonUnion, {k: 'age'}>['v']; // type is number

The result is that we can put our object type back together again:

type ReformedPerson = Objectify<PersonUnion>;
// type is {
// age: number;
// name: string;
// greet: () => string;
// }

So we have a complete mapping between object types and unions of key/value pairs. Now we can put conditional types to work on their home turf! Let's see what this lets us do.

OmitProperties with the new helpers

First, OmitProperties. It's easy to filter a k/v pair based on the value type using a conditional:

type OmitKV<T extends KVPair, V> = T extends {v: V} ? never : T;

type KV1 = OmitKV<{k: 'age', v: number}, Function>;
// type is { k: "age"; v: number; }
type KV2 = OmitKV<{k: 'greet', v: () => string}, Function>;
// type is never

Now the fun part! Because OmitKV is a conditional type, it distributes over unions. And in a type union, never disappears:

type KVs = OmitKV<PersonUnion, Function>;
// type is { k: "age"; v: number; } |
// { k: "name"; v: string; }

By sandwiching OmitKV between Unionize and Objectify, we can take the long way around (as in the diagram at the start of the post) and get an OmitProperties implementation:

type OmitProperties<T extends object, V> = Objectify<OmitKV<Unionize<T>, V>>;

type T = OmitProperties<Person, Function>;
// type is {
// name: string;
// age: number;
// }

The greet property, which was a function, is really, truly gone! 🤩

You can implement the opposite operation, PickProperties, in a similar way:

type PickKV<T extends KVPair, V> = T extends {v: V} ? T : never;
type PickProperties<T extends object, V> =
Objectify<PickKV<Unionize<T>, V>>;

type PersonStrings = PickProperties<Person, string>;
// type is { name: string }

function indexByField<T extends object>(
obj: T[],
field: keyof PickProperties<T, string>
) { /* ... */ }

indexByField(people, 'name'); // OK
indexByField(people, 'greet');
// ~~~~~ Argument of type '"greet"' is not assignable
// to parameter of type '"name"'. (2345)

Of course, if you just want the keys then you don't need to go back through Objectify. Something simpler accomplishes the same thing:

type PickKeys<T extends object, V> =
Extract<Unionize<T>, {v: V}>['k'];

function indexByField<T extends object>(
obj: T[],
field: PickKeys<T, string>
) { /* ... */ }

Jsonify with Unionize and Objectify

What other problems can you solve with this technique?

Looking back at Jsonify, we can make it filter out Function values:

// (opposite of PickKeys, above)
type OmitKeys<T extends object, V> = Exclude<Unionize<T>, {v: V}>['k'];

type Jsonify<T> = T extends {toJSON(): infer U}
? U
: T extends Array<infer U>
? Array<Jsonify<U>>
: T extends object
? {
[k in OmitKeys<T, Function>]: Jsonify<T[k]>;
}
: T;

function jsonRoundTrip<T>(x: T): Jsonify<T> {
return JSON.parse(JSON.stringify(x));
}

const v = jsonRoundTrip({
name: 'Bobby',
age: 12,
greet() { return 'Hi!'; },
});
// type is {
// name: string;
// age: number;
// }

The greet method is gone entirely, just as it should be. Amazing!

Lodash's _.invert

Yet another application (and the one that introduced me to this technique) is precisely typing lodash's _.invert(), which swaps the keys and values in an object:

const shortToLong = {
p: 'pageNum',
n: 'numResults',
};

const longToShort = _.invert(shortToLong); // what's the type?

As of this writing, the type you get using @types/lodash is just _.Dictionary<string>, which isn't wrong, but also isn't very precise. You can get a more precise result using keyof:

type Inverted<T extends object> = Record<string, keyof T>;
type T = Inverted<typeof shortToLong>;
// type T = {
// [x: string]: "p" | "n";
// }

The shortToLong constant is probably intended to be entirely immutable, so we can use a const assertion to get a more precise type:

const shortToLong = {
p: 'pageNum',
n: 'numResults',
} as const;
// type is { readonly p: 'pageNum'; readonly n: 'numResults'; }

Now we should be able to get a really precise type for the inverse! It should be {pageNum: 'p'; numResults: 'n';}. Let's see how Unionize and Objectify can help us get there.

First of all, swapping the k and v in a k/v pair is easy:

type SwapKV<T> =
T extends {k: infer K, v: infer V}
? {k: V; v: K; } // <-- note the swap!
: never;

Here we've used the infer keyword (a conditional types superpower) to pull out the key and value types from a k/v pair.

If you try to wrap this in Unionize and Objectify, you'll get a very long, cryptic error. I'll spare you the full message, but the root cause is that Objectify requires that k be a PropertyKey and there's no guarantee that V is assignable to that. If we bake in that constraint, then everything works:

type SwapKV<T> =
T extends {k: infer K, v: infer V}
? V extends PropertyKey // <-- additional PropertyKey constraint
? {k: V; v: K; } // <-- note the swap!
: never
: never;
type Inverted<T extends object> = Objectify<SwapKV<Unionize<T>>>;

Now we get perfect types:

type T = Inverted<typeof shortToLong>;
// type T = {
// pageNum: "p";
// numResults: "n";
// }

If you drop the as const, you'll get a less-precise type, just like before. It would be nice to restrict Inverted to only allow types with PropertyKey values, but I'll leave that as an exercise to the reader.

If you have duplicate values, you get a union of the values for the key type:

const playerToTeam = {
a: 'A',
b: 'A',
c: 'B',
} as const;

type T = Inverted<typeof playerToTeam>;
// type T = {
// A: "a" | "b";
// B: "c";
// }

This seems sensible since TypeScript doesn't have a notion of the order of keys in an object or elements in a union. I recommend working out the sequence of operations yourself to see how this union ("a" | "b") comes about.

Conclusion

Ever since I learned about them, I've been finding more and more uses for Unionize and Objectify. You've seen three of them in this post, but I'm sure there are many others. They have a real knack for transforming difficult problems with object types into much simpler problems with union types. Next time you run into a problem with types, think about whether unionization can help!

To experiment with the code samples in this post, use this playground link.

A huge thanks to Titian for introducing me to this! He uses "AllValues" instead of "Unionize". If you don't like the names, feel free to choose your own. You could go with "ToPairs" and "FromPairs" to match lodash, or "ToUnion" and "ToObject". If you just want something like OmitProperties, take a look at ts-essential's OmitProperties.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »