Writing a Recursive Utility Type in TypeScript
I recently dealt with a tricky TypeScript situation. GraphQL servers have a feature where for any response it returns, it adds a __typename
within every object corresponding to its type’s name.
This is what a typical response looks like:
{
"__typename": "SolarSystem",
"id": 123,
"name": "The Solar System",
"star": {
"__typename": "Planet",
"id": 123,
"name": "Sun",
"size": 12345,
"inhabitants": null
},
"planets": [
{
"__typename": "Planet",
"id": 123,
"name": "Earth",
"size": 12345,
"inhabitants": [
{
"__typename": "LifeForm",
"id": 123,
"name": "Human"
}
]
}
]
}
When a GraphQL codegen script runs, this __typename
field gets eventually imported into TypeScript world and makes its way into the generated schema:
interface SolarSystem {
__typename: "SolarSystem";
id: number;
name: string;
star: Planet;
planets: Planet[];
}
interface Planet {
__typename: "Planet";
id: number;
name: string;
size: number;
inhabitants: LifeForm[] | null;
}
interface LifeForm {
__typename: "LifeForm";
id: number;
name: string;
}
Here is a component we have to display a LifeForm
entity using the schema types:
interface EntityProps<T> {
entity: T;
}
function LifeForm({ entity }: EntityProps<LifeForm>) {
return <>{entity.name}</>;
}
In production, we will typically invoke it using a GraphQL query:
const { data } = useLifeFormQuery();
return <LifeForm entity={data} />;
But what if we want to mock it?
<LifeForm
entity={{
__typename: "LifeForm", // This is required, but we will never use it!
name: "Humans",
}}
/>
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
Suddenly, __typename
becomes a burden. Our component APIs require something which we will never use (and if we do use it, it would mean coupling our view components to GraphQL’s implementation details – not good!). In this case, getting rid of the __typename
requirement is easy: use Omit<T, K>
!
interface EntityProps<T> {
entity: Omit<T, "__typename">;
}
// ...
<LifeForm
entity={{
name: "Humans",
}}
/>;
But what if instead of LifeForm
, we need the whole SolarSystem
?
interface EntityProps<T> {
entity: Omit<T, "__typename">;
}
function SolarSystem({ entity }: EntityProps<SolarSystem>) {
return <>{entity.name}</>;
}
<SolarSystem
entity={{
//__typename: "SolarSystem",
id: 123,
name: "The Solar System",
star: {
__typename: "Planet",
id: 123,
inhabitants: null,
name: "Sun",
size: 9999,
},
planets: [
{
__typename: "Planet",
id: 123,
name: "Earth",
size: 12345,
inhabitants: [
{
__typename: "LifeForm",
id: 123,
name: "Human",
},
],
},
],
}}
/>;
The Omit<T, K>
allows us to omit the first __typename
, but what about the rest? Is it possible to remove all __typename
without rewriting the type definition by hand?
Using Mapping Types to Override Definitions⌗
The most practical way of overriding a type definition is by using mapped types.
The TypeScript Handbook: When you don’t want to repeat yourself, sometimes a type needs to be based on another type. Mapped types build on the syntax for index signatures, which are used to declare the types of properties which have not been declared ahead of time.
The most basic mapped type is one that duplicates another type:
interface MyType {
foo: number;
bar: boolean;
baz: "hello" | "world";
}
type MyMappedType = {
[K in keyof MyType]: MyType[K];
};
// Or more generically...
type Duplicate<T> = {
[K in keyof T]: T[K];
};
type MyGenericallyMappedType = Duplicate<MyType>;
Mapped types can extend, restrict, or modify entirely the type they are operating on:
interface MyType {
foo: number;
bar: boolean;
baz: "hello" | "world";
}
type AllowNumbers<T> = {
[K in keyof T]: T[K] | number;
};
const allowedNumbers: AllowNumbers<MyType> = {
foo: 5,
bar: 6,
baz: 7,
};
type DisallowNumbers<T> = {
[K in keyof T]: Exclude<T[K], number>;
};
const disallowedNumbers: DisallowNumbers<MyType> = {
foo: 5, // Error, no numbers allowed
bar: true,
baz: "hello",
};
type OnlyNumbers<T> = {
[K in keyof T]: number;
};
const onlyNumbers: OnlyNumbers<MyType> = {
foo: 5,
bar: true, // Error, only numbers allowed
baz: "hello", // Error, only numbers allowed
};
type DoubleWrapped<T> = {
[K in keyof T]: { [P in K]: T[K] }; // Nested remapping. P == K
};
const doubleWrapped: DoubleWrapped<MyType> = {
foo: { foo: 5 },
bar: { bar: true },
baz: { baz: "hello" },
};
The TypeScript Handbook has other interesting examples.
Of particular interest to us is the ability to apply the Omit<T, K>
utility to all children of a type:
interface FooType {
__typename: "First";
name: string;
child: {
__typename: "Second";
name: string;
child: {
__typename: "Third";
name: string;
};
};
}
type OmitFromChildren<T, K extends string> = {
[P in keyof T]: Omit<T[P], K>;
};
const test: Omit<OmitFromChildren<FooType, "__typename">, "__typename"> = {
// __typename: "First";
name: "Top level",
child: {
// __typename: "Second",
name: "Child",
child: {
__typename: "Third", // Still Required
name: "Leaf",
},
},
};
The above allows us to use Omit<T, K>
two levels deep. We will later reuse the mapping type technique to apply Omit<T, K>
recursively to a complete tree.
Limitations of Omit⌗
Omit<T, K>
has a few surprising edge cases that we need to cover before proceeding:
Unions⌗
Under the hood, Omit<T, K>
is implemented using Exclude
in such a way, that passing a union type to it produces nonsense.
interface FooType {
__typename: "First";
name: string;
}
interface FooType2 {
__typename: "First";
name2: string;
}
// No static typing is applied
const test: Omit<FooType | FooType2, "__typename"> = {
name: 123, // Wrong type, but no errors :hmm:
};
The reason why this behaves the way it does would be a blog post on its own, but suffice to say that the way to fix it is to distribute the union.
Tip: To Distribute means to check every member of a union seperately.
A union can be forcefully distributed by using the extends
operator:
// Every type extends `unknown`
type UnionOmit<T, K extends string | number | symbol> = T extends unknown
? Omit<T, K>
: never;
As a sidenote, UnionOmit
turns out to be simply a more flexible version of Omit<T, K>
. I do not see any value in using Omit<T, K>
by itself anymore.
Once the types are distributed, type checking is restored:
interface FooType {
__typename: "First";
name: string;
}
interface FooType2 {
__typename: "First";
name2: string;
}
type UnionOmit<T, K extends string | number | symbol> = T extends unknown
? Omit<T, K>
: never;
const test: UnionOmit<FooType | FooType2, "__typename"> = {
name: 123, // TypeError: type checking is back!
};
Nullable Types⌗
You may think that a nullable type, defined as T | null
is just a regular union, but it sometimes behaves differently than other types. I’ll admit that I don’t have this part fully figured out, but my working theory is that null
types are sticky and do not distribute like other types.
type UnionOmit<T, K extends string | number | symbol> = T extends unknown
? Omit<T, K>
: never;
interface FooType {
__typename: "First";
name: string;
}
// No static typing is applied
const test: Omit<FooType | null, "__typename"> = {
name: 123, // Wrong type, but no errors :hmm:
};
// No static typing is applied... AGAIN!
const test2: UnionOmit<FooType | null, "__typename"> = {
name: 123, // Wrong type, but no errors :hmm:
};
We can forcefully distribute them using a different technique:
type UnionOmit<T, K extends string | number | symbol> = T extends unknown
? Omit<T, K>
: never;
type NullUnionOmit<T, K extends string | number | symbol> = null extends T
? UnionOmit<NonNullable<T>, K> | null
: UnionOmit<T, K>;
interface FooType {
__typename: "First";
name: string;
}
const test: NullUnionOmit<FooType | null, "__typename"> = {
name: 123, // TypeError: type checking is back!
};
const test2: NullUnionOmit<FooType | null, "__typename"> = null; // Nulls are legal
Functions⌗
A function is a special kind of object: it has the property of being callable. TypeScript considers this property fragile enough to strip it from any function definition that has been touched by Omit<T, K>
:
type OmittedFunction = Omit<() => true, "key">;
let func: OmittedFunction = () => true;
func(); // This expression is not callable.
// Type 'OmittedFunction' has no call signatures.
One solution to this is to specifically avoid touching functions:
type FunctionSafeOmit<T, K extends string> = T extends Function
? T
: Omit<T, K>;
type OmittedFunction = FunctionSafeOmit<() => true, "key">;
let func: OmittedFunction = () => true;
func();
There is nothing wrong with this method technically, but it does not inspire confidence. What if functions are not the only type that breaks in this way? While it might be the case today, new special types are added to JavaScript (Classes, Promises, Symbols, Proxies, etc) every few years. It might not be true tomorrow.
This brings us to the second, preferred solution. Instead of blacklisting every type individually, we can selectively apply Omit<T, K>
to the types which have the property we wish to omit:
type SafeOmit<T, K extends string> = T extends { [P in K]: any }
? Omit<T, K>
: T;
type OmittedFunction = SafeOmit<() => true, "key">;
let func: OmittedFunction = () => true;
func();
Writing Recursive Generics⌗
Time to breathe a breath of fresh air: The difficult parts are behind us. Recursive Generics in TypeScript may be tricky at times, but they don’t have gotchas. As long as you do not attempt to write an infinitely deep expression, it should be fine.
A recursive mapping type looks like this:
type Recursive<T> = {
[P in keyof T]: Recursive<T[P]>;
} & { changed: true };
If we want to recurse conditionally, we need to only recurse when appropriate:
// Do not recurse on specific structure
type Recursive<T> = {
[P in keyof T]: T[P] extends { value: number } ? T[P] : Recursive<T[P]>;
} & { changed: true };
The above will stop recursing on any branch if it hits a match. If our goal is to recurse on the whole type while applying an Omit<T, K>
, the recursion must always happen, but the application becomes conditional:
type Recursive<T> = T extends { value: number }
? { [P in keyof T]: Recursive<T[P]> }
: {
[P in keyof T]: Recursive<T[P]>;
} & { changed: true };
let test: Recursive<{ foo: { bar: { value: 3 }; baz: { value: true } } }> =
null as any;
test.foo.bar.changed; // Property 'changed' does not exist on type
test.foo.baz.changed; // Property 'changed' exists
The duplicated portion of the definition can be extracted into a helper type:
type RecursiveHelper<T> = { [P in keyof T]: Recursive<T[P]> };
type Recursive<T> = T extends { value: number }
? RecursiveHelper<T>
: RecursiveHelper<T> & { changed: true };
let test: Recursive<{ foo: { bar: { value: 3 }; baz: { value: true } } }> =
null as any;
test.foo.bar.changed; // Property 'changed' does not exist on type
test.foo.baz.changed; // Property 'changed' exists
Putting It All Together⌗
type UnionOmit<T, K extends string | number | symbol> = T extends unknown
? Omit<T, K>
: never;
type NullUnionOmit<T, K extends string | number | symbol> = null extends T
? UnionOmit<NonNullable<T>, K>
: UnionOmit<T, K>;
type RecursiveOmitHelper<T, K extends string | number | symbol> = {
[P in keyof T]: RecursiveOmit<T[P], K>;
};
type RecursiveOmit<T, K extends string | number | symbol> = T extends {
[P in K]: any;
}
? NullUnionOmit<RecursiveOmitHelper<T, K>, K>
: RecursiveOmitHelper<T, K>;
const cleanSolarSystem: RecursiveOmit<SolarSystem, "__typename"> = {
//__typename: "SolarSystem",
id: 123,
name: "The Solar System",
star: {
//__typename: "Planet",
id: 123,
inhabitants: null,
name: "Sun",
size: 9999,
},
planets: [
{
//__typename: "Planet",
id: 123,
name: "Earth",
size: 12345,
inhabitants: [
{
//__typename: "LifeForm",
id: 123,
name: "Human",
},
],
},
],
};
Bonus Feature⌗
As a cherry on top, not only does T
accept unions, but so does K
:
interface Foo {
a: 3;
b: 4;
c: 5;
}
type Bar = RecursiveOmit<Foo, "a" | "b">;
let x: Bar = {
c: 5,
};