Deep flatten types in TypeScript

Błażej Kustra
Software Mansion
Published in
5 min readJan 7, 2024

--

Deep Flatten types in typescript

“How deep does the rabbit hole go?” 💊 a famous line from the Matrix movie often comes to my mind when dealing with complex recursive types in TypeScript.

Nested object types can be a real pain to work with, especially when recursion comes into play. What if I told you that you could flatten an object with nested properties? Not just flatten, but transform it from {a: { b: { c: string }}} to { 'a.b.c': string } format.

But beware of the rabbit hole, you might find it deeper than you think. Ready to jump in?

The goal

Suppose we have a complex and arbitrarily nested TypeScript type called Foo:

type Foo = {
array: { timestamp: Date }[];
nested: { optionalStr?: string; unknown: unknown };
set: Set<string>;
};

The goal is to preserve the types, but flatten the objects such that the properties are now on the root level:

type FlattenedFoo = FlattenObject<Foo>;
{
array: Array<{ timestamp: Date }>;
[x: `array[${bigint}]`]: { timestamp: Date };
[x: `array[${bigint}].timestamp`]: Date;

nested: { optionalStr?: string; unknown: unknown };
'nested.optionalStr': string | undefined;
'nested.unknown': unknown;

set: Set<string>;
};

Motivation

You might be wondering why anyone would need such a complicated type? Some time ago I stumbled on problem while developing Typescript modeling tool for DynamoDB — dynamode.

Dynamode motto, a better way of using DynamoDB in typescript

If you’ve ever worked with DynamoDB, a NoSQL database service from Amazon, you’ll likely be aware that the data is stored in a nested JSON-like format. While the database is quite efficient it becomes problematic when you want to query or update nested fields, there is no type safety and auto-completion out of the box.

That’s precisely the point at which FlattenObject came into action. It served the dual purpose of bringing type safety and auto-completion to the table, simply by flattening the defined model.

Autocomplete and type safety 🧙‍♂️

For more details read this article about Dynamode / Leave a star on Github ⭐️

Understand FlattenObject

How about we tackle each part of this type and understand it piece by piece? Let’s dissect the FlattenObject:

type FlattenObject<TValue> = CollapseEntries<CreateObjectEntries<TValue, TValue>>;

The FlattenObject is designed as a composition of CreateObjectEntries, responsible for exploding an object into its entry elements and CreateObjectEntries tasked with collapsing these entries back into a flat object.

CreateObjectEntries<TValue, TValueInitial>

CreateObjectEntries creates a union of entries that look like this:

type Entry = { key: string; value: unknown };

For a hypothetical type {a: string, b?: number} you’ll get {key: "a", value: string} | {key: "b", value: number | undefined} as an exploded outcome.

type EmptyEntry<TValue> = { key: ''; value: TValue };

type CreateObjectEntries<TValue, TValueInitial> = TValue extends object
? {
// Checks that Key is of type string
[TKey in keyof TValue]-?: TKey extends string
? // Nested key can be an object, run recursively to the bottom
CreateArrayEntry<TValue[TKey], TValueInitial> extends infer TNestedValue
? TNestedValue extends Entry
? TNestedValue['key'] extends ''
? {
key: TKey;
value: TNestedValue['value'];
}
:
| {
key: `${TKey}.${TNestedValue['key']}`;
value: TNestedValue['value'];
}
| {
key: TKey;
value: TValue[TKey];
}
: never
: never
: never;
}[keyof TValue] // Builds entry for each key
: EmptyEntry<TValue>;

It’s a recursive type with a base condition TValue extends object ? … : EmptyEntry<TValue> that checks if TValue is an object. Each object property then calls CreateArrayEntry<TValue[TKey], TValueInitial> in order to handle exceptions.

The rest is simple, just keep building the union of entries, both for nested keys ${TKey}.${TNestedValue[‘key’]} and the TKey itself:

TNestedValue['key'] extends ''
? {
key: TKey;
value: TNestedValue['value'];
}
:
| {
key: `${TKey}.${TNestedValue['key']}`;
value: TNestedValue['value'];
}
| {
key: TKey;
value: TValue[TKey];
}
: never

Tackle the Exceptions

CreateArrayEntry<TValue, TValueInitial>

When the value is an array we need to transform it to an artificial object with key encoded as `[${bigint}]`. For a type string[] you’ll get { [k: `[${bigint}]`]: string } as an outcome.

type ArrayEncoder = `[${bigint}]`;

// Transforms array type to object
type CreateArrayEntry<TValue, TValueInitial> = OmitItself<
TValue extends unknown[] ? { [k: ArrayEncoder]: TValue[number] } : TValue,
TValueInitial
>;

OmitItself<TValue, TValueInitial>

What if our type references itself? We want to avoid flattening infinitely 😅 For a type type Foo = { nestedFoo: Foo }you’ll get { key: “nestedFoo”; value: Foo; } as an outcome, otherwise call OmitExcludedTypes. That’s why all types have the TValueInitial parameter, to keep track of the initial type.

// Omit the type that references itself
type OmitItself<TValue, TValueInitial> = TValue extends TValueInitial
? EmptyEntry<TValue>
: OmitExcludedTypes<TValue, TValueInitial>;

OmitExcludedTypes<TValue, TValueInitial>

There are some types that we don’t want to flatten, instead we just return an empty entry { key: ''; value: Date | Set | Map } otherwise call CreateObjectEntries back.

type ExcludedTypes = Date | Set<unknown> | Map<unknown, unknown>;

// Omit the type that is listed in ExcludedTypes union
type OmitExcludedTypes<TValue, TValueInitial> = TValue extends ExcludedTypes
? EmptyEntry<TValue>
: CreateObjectEntries<TValue, TValueInitial>;

CollapseEntries<TEntry>

The last part is to collapse entries and build back the initial object. This is also quite straight forward, iterate on every entry and transform it to key value format.

type CollapseEntries<TEntry extends Entry> = {
[E in TEntry as EscapeArrayKey<E['key']>]: E['value'];
};

EscapeArrayKey<TKey>

In my particular case I needed to get rid of dots in front of every array, otherwise just return the key.

type EscapeArrayKey<TKey extends string> = TKey extends `${infer TKeyBefore}.${ArrayEncoder}${infer TKeyAfter}`
? EscapeArrayKey<`${TKeyBefore}${ArrayEncoder}${TKeyAfter}`>
: TKey;

It works! 🥁

You can find the source code here, or explore it on Typescript Playground.

Special thanks to Joe Calzaretta who inspired me with this approach here.

--

--

Software engineer at Software Mansion 🤓 Creator of Dynamode 🗂️ - modeling tool for Amazon's DynamoDB