DeepKeys and DeepValues utility types
- typescript
I like TypeScript the most when I don’t have to care much about it. It’s not just a question of laziness—the compiler is (in most cases) actually better at reasoning about the type flow than I am. Yes, in most cases. Sometimes the type inference reaches it’s limits, and the compiler needs a hand. This is the time for utility types.
A good example might be getting the right types of keys or values for an object. I put a simple example (all branches have the same depth so far) below. It’s a slice of an object of default colors from the first version of Tailwind CSS.
Is there a simple way to pluck a first, second, etc. level of keys or values so we can use them as TypeScript types?
const tailwind_colors_v1 = { gray: { 100: "#f7fafc", 200: "#edf2f7", 300: "#e2e8f0", // ... }, red: { 100: "#fff5f5", 200: "#fed7d7", 300: "#feb2b2", // ... }, orange: { 100: "#fffaf0", 200: "#feebc8", 300: "#fbd38d", // ... }, // ... } as const;
Let’s assume the object is constant (therefore the as const
); it’s not necessary, but it’s often more useful to get the literal type, e.g., #f7fafc
, than just plain string
.
The warmup: keyof
keyword and bracket notation
For starters, we can try the keyof
keyword and bracket notation combo to access the keys and values:
// first level of nesting type KeysOfColors = keyof typeof tailwind_colors_v1; type ValuesOfColors = (typeof tailwind_colors_v1)[keyof typeof tailwind_colors_v1];
This actually works fine (or does it?) on one level of an object, but it gets very ugly quickly if we need to pluck nested keys or values:
// second level of nesting type ShadesOfColors = keyof (typeof tailwind_colors_v1)[keyof typeof tailwind_colors_v1]; type HexValuesOfColors = (typeof tailwind_colors_v1)[keyof typeof tailwind_colors_v1][keyof (typeof tailwind_colors_v1)[keyof typeof tailwind_colors_v1]];
That’s… not very elegant. But that’s not the greatest issue. I spent literally 15 minutes just trying to put together all the brackets in the right order, which indicates there is a much more important problem than aesthetics: it’s utterly unmaintainable.
Does it work? Let’s assume it does. Here’s the Playground link.
First (naïve) utility types
Is it possible to make it better? We’ll see! Let’s throw in some simple utility types with generics:
type ObjectKeys<T extends object> = keyof T; type ObjectValues<T extends object> = T[keyof T]; // first level of nesting type KeysOfColors = ObjectKeys<typeof tailwind_colors_v1>; type ValuesOfColors = ObjectValues<typeof tailwind_colors_v1>; // second level of nesting type ShadesOfColors = ObjectKeys<ObjectValues<typeof tailwind_colors_v1>>; type HexValuesOfColors = ObjectValues<ObjectValues<typeof tailwind_colors_v1>>;
Does it look better? Maybe. Does it work? Well, not exactly, although it may look fine on the Playground.
Why did I say it did not work? It has a severe limitation: It will break as soon as we modify the source object to have a different branch depth (as it actually has in real life):
const tailwind_colors_v1 = { transparent: "transparent", current: "currentColor", black: "#000", white: "#fff", gray: { "100": "#f7fafc", "200": "#edf2f7", "300": "#e2e8f0", // ... }, red: { "100": "#fff5f5", "200": "#fed7d7", "300": "#feb2b2", // ... }, orange: { "100": "#fffaf0", "200": "#feebc8", "300": "#fbd38d", // ... }, // ... } as const;
If we take a look at the Playground, we can see that the types of the first level of depth are inferred fine (both with and without the utility), but the second ones are broken: in both cases, they are inferred to never
and with the utility, they are causing an error: “(the given type…) does not satisfy the constraint ‘object’.”
Improved utility types
Let’s be honest: Our utility types were kind of naïve. We need to check if the type conversion actually makes sense. Let’s try again with conditional types (the extends
keyword and ternary):
type ObjectKeys<T> = T extends object ? keyof T : never; type ObjectValues<T> = T extends object ? T[keyof T] : never;
Now, we are almost there (take a look at the Playground), but there is a hidden footgun there. The first level is fine, but in the case of the second, the inference is misleading. IMO, the types for ShadesOfColors
should not be "100" | "200" | "300"
, but "100" | "200" | "300" | undefined
. See why? Because on some of the branches, they are not present at all.
The fix is pretty easy here. Just switch never
to undefined
and we are fine (check the Playground):
type ObjectKeys<T> = T extends object ? keyof T : undefined; type ObjectValues<T> = T extends object ? T[keyof T] : undefined;
So this is our baseline. What’s next? The actual recursive types that would give us every accessible level of keys or values.
Recursive types
I’ll start in the opposite direction here. Let’s first take a quick peek at the results:
type firstLevelKeys = DeepKeys<typeof tailwind_colors_v1, 0>; type secondLevelValues = DeepValues<typeof tailwind_colors_v1, 1>;
The signature of these types is rather self-explanatory: these utilities will give us the keys or values of the nth level of an object.
At first, we will create two helper functions that will serve a simple purpose: decrementing the counter. Unfortunately, the only way in TypeScript to increment or decrement a variable is to store a tuple (or array literal) as a type argument and increase or decrease its length. That’s why a simple purpose needs a quite complex solution:
// This is a utility for creating an array of length N // e.g., Arr<3> // [any, any, any] type Arr<N extends number, T extends any[] = []> = T['length'] extends N ? T : Arr<N, [...T, any]>; // This is a utility for decrementing a number by one // e.g., Decrement<5> // 4 type Decrement<N extends number> = Arr<N> extends [any, ...infer U] ? U['length'] : never;
The DeepValues
and DeepKeys
utils will look like this:
type DeepKeys<T, Counter extends number = 0> = T extends object ? Counter extends 0 ? keyof T : DeepKeys<T[keyof T], Decrement<Counter>> : never; type DeepValues<T, Counter extends number = 0> = T extends object ? Counter extends 0 ? T[keyof T] : DeepValues<T[keyof T], Decrement<Counter>> : never;
What’s happening here? A recursion with the base case set using the Counter
variable (which defaults to 0
). If the counter is 0
, the utility returns keys or values from the depth it has reached; otherwise, it moved one level deeper.
As always, these types can be seen in action on the Playground.
Final touch
Are we there yet? Almost. But the utility types still need a little tune-up; it is slightly dangerous in the way it does not warn that these types are not accessible on all branches. We need to add undefined
to the mix if we pass by any leaf branch (a branch that ends at the current depth).
How is it done? We can’t resolve this when the base case is reached; it would be too late. We need to take notes while traversing. It is a similar approach to graph traversal (what we are doing is actually a breadth-first search) and taking bite marks. We just take a look to see if all leaves are objects, and if not, we note that. However deep we got, we’ll know that there was a shorter branch up there, and we need to add undefined
to the final result.
To do so, we’ll create two more simple helper types, Uncertain
and AreAllValuesObjects
. We also add a third type argument, Guaranteed
, which is a simple bite-mark boolean flag.
type Uncertain<T> = T | undefined; type AreAllValuesObjects<T extends object> = T[keyof T] extends object ? true : false; type DeepKeys<T, Counter extends number = 0, Guaranteed extends boolean = true> = T extends object ? Counter extends 0 ? Guaranteed extends true ? keyof T : Uncertain<keyof T> : DeepKeys<T[keyof T], Decrement<Counter>, AreAllValuesObjects<T>> : never; type DeepValues<T, Counter extends number = 0, Guaranteed extends boolean = true> = T extends object ? Counter extends 0 ? Guaranteed extends true ? T[keyof T] : Uncertain<T[keyof T]> : DeepValues<T[keyof T], Decrement<Counter>, AreAllValuesObjects<T>> : never;
As always, I created a Playground to fiddle with these types.
The code above, however complex it might look, is still pretty limited. For example, its output becomes pretty useless (although not broken!) as soon as one of the values is an array. But handling that would be beyond the scope of this article.
Why all of this?
That’s actually a great question! I totally understand what someone who just started with TypeScript might think about that, and I don’t dare say it because I am a decent person. But in fact, it is not so complicated; what makes it look so are the limitations of the compiler, resulting in rather simple ideas having to be expressed in a way that does not look simple at all. In plain JavaScript, the code with equal intent would be expressed much more concisely.
Using TypeScript like this might be considered overkill for an app’s code, and I totally get that. But it can be really helpful when used by a library. I had a revelation when first migrating to TypeScript in an app that was using original, “vanilla” Redux. And it was a pain—red squiggly lines everywhere. But Redux Toolkit was a completely different story: it was bliss, type inferrence working flawlessly, types flowing effortlessly up and down. It was obvious that the contributors got very good at TypeScript in the meantime and have fully exploited its potential. Other great examples? React Query, tRPC, and many others.
☘ Lucky coding!
last modified
If you find anything in this post that should be improved (either factually or in language), feel free to edit it on Github .