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:
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:
- They distribute over unions.
- 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:
|
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?
|
OmitProperties
One idea is to use mapped types and conditional types together:
|
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:
|
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:
|
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
:
|
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
:
|
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
:
|
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:
|
Then we use Extract
to find the k/v pair for each key and pull out the corresponding value:
|
The result is that we can put our object type back together again:
|
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:
|
Now the fun part! Because OmitKV
is a conditional type, it distributes over unions. And in a type union, never
disappears:
|
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:
|
The greet
property, which was a function, is really, truly gone! 🤩
You can implement the opposite operation, PickProperties
, in a similar way:
|
Of course, if you just want the keys then you don't need to go back through Objectify
. Something simpler accomplishes the same thing:
|
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:
|
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:
|
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
:
|
The shortToLong
constant is probably intended to be entirely immutable, so we can use a const assertion to get a more precise type:
|
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:
|
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:
|
Now we get perfect types:
|
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:
|
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
.