In TypeScript, you often need to take subsets of types. There are several utility types that can help you achieve this, such as Pick, Omit, and Partial.
However, these utility types at scale can feel pretty clunky and can lead to verbose code. To address this, I worked on a feature called nested type selection.
If you have ever worked with GraphQL, you might recognize the pattern of nested type selection. This pattern allows you to create a new type that is a subset of an existing type, while also allowing you to specify the properties that you want to include explicitly.
In large, the need for this utility type arose from the code generation capabilities of GraphQL tools. These tools often generate types that are nested within other types, and it can be difficult to extract the necessary information from these deeply nested types.
To that end, I showcase here the Select utility type.
The Current Condition
Let's start with the origin type definition. This will be a type that has various nested properties which we want to pick out from the type.
The original types, which might be generated by an OpenAPI or GraphQL Codegen tool, could look something like this:
interface Post {
id: string;
title: string;
content: string;
author: User;
}
interface User {
id: string;
email: string;
name: string;
age: number;
address: {
street: string;
city: string;
state: string;
zip: string;
};
friends: Array<User>;
posts: Array<Post>;
};
Especially with GraphQL, which allows recursive type relationships, data can often become deeply nested. Defining a type based off this normally would require a combination of Pick or Omit and intercepting the type with itself, redefining properties as references to the original type.
Let's start with a React component. Best practices would be to define a type for this component's props that require only what is referenced.
function Post(props: PostComponentProps) {
const { friends } = props.post.author;
const totalFriends = friends.length;
const friendsBlock = useMemo(() =>
totalFriends > 1
? <p>Friends with <b>{friends[0].name}</b> and {totalFriends - 1} others</p>
: totalFriends === 1
? <p>Friends with <b>{friends[0].name}</b></p>
: null, [totalFriends]);
return <div>
<h2>{props.post.title}</h2>
<p>By {props.post.author.name}</p>
{friendsBlock}
</div>;
}
Now let's define a type based on the API generated types. If you do this ad-hoc, you might have the, albeit redundant, simple type definition:
interface PostComponentProps {
post: {
id: string;
title: string;
author: {
id: string;
name: string;
friends: Array<{
id: string;
name: string;
}
}
}
}
This works, but it is not totally ideal. We had to redundantly define each property's type and ensure it matches the original type, otherwise the API data could not be utilized with it. This can be cumbersome and error-prone, especially during development, when dealing with deeply nested structures, or with complicated data structures which are often the case with generated types.
If it is difficult, developers may try to escape the rules of the system by referring to types directly. However, a domain model or UI component should only specify the properties it truly needs to function, not egregious top-level type definitions that can contain circular references or other complex structures.
This is an example where you are putting yourself in a position where not only is the type complicated, but also virtually impossible to satisfy:
interface PostComponentProps {
post: Post;
}
Technically, all the data we need for the component will be available, but so will a lot more data we don't need, and likely don't have available, especially with GraphQL's "request only what you need" philosophy.
Now, the component must be passed a fully satisfied Post object. Unfortunately, this has circular references and much more data than is needed to function.
An Example with Type Selection
The alternative I propose here works very similarly to the functionality of selection sets in GraphQL[todo ref].
You define what properties of a defined type you want to select, and the resulting type will only contain those properties. This also works for nested objects and arrays. It is a very expressive and lightweight way of defining the shape you want from some type.
import { Select } from './select';
interface PostComponentProps {
post: Select<Post, {
id
title
author: {
id
name
friends: {
id
name
}
}
}>
}
The resulting PostComponentProps type is equivalent to the inline definition in our first example. However, the type of post is entirely described by the shape of the data we need for the component based on some known supertype Post, ensuring compatibility with the API and practicing least privileges.
In terms of behavior around arrays, it follows the same rules as GraphQL's selection sets. If you select a field that is an array, you can also select fields within the array elements. The resulting type infers the shape of the array elements based on the selection set and intelligently handles cases of arrays vs objects vs primitives.
Types and the LSP Server
A major developer experience improvement offered in this approach is that the TypeScript language server will provide auto-suggestions for properties as you are filling out the Select type. This makes it easier to ensure that the shape of the data you are using matches the shape of the data you need for the component. You can also prompt the auto-suggest in your editor and what properties are available will be displayed.
The Magic Type
Here is the source for the type. There are certain limitations to this code currently, but it covers the majority of use cases. Given some interface, a selection set can be provided that describes the shape of the data you want, and the resulting type will included only the properties provided in that selection set.
type Primitive = string | number | boolean | null | undefined;
type IndexableObject = Record<string, unknown>;
type AnyArray = Array<unknown>;
type ArrayType<T> = T extends Array<infer U> ? U : never;
type Sentinel = undefined;
type SelectionOfObject<Source extends IndexableObject> = Partial<{
[K in keyof Source]: SelectionOfUnknown<Source[K]>;
}>;
type SelectionOfArray<Source extends AnyArray> = Partial<{
[K in keyof ArrayType<Source>]: SelectionOfUnknown<ArrayType<Source>[K]>;
}>;
type SelectionOfUnknown<Source> = Source extends AnyArray
? SelectionOfArray<Source>
: Source extends IndexableObject
? SelectionOfObject<Source>
: Sentinel;
export type Select<Source extends IndexableObject | AnyArray, Selection extends SelectionOfUnknown<Source>> = {
[K in keyof Selection & keyof Source]: Selection[K] extends Sentinel
? Source[K]
: Source[K] extends AnyArray
? ArrayType<Source[K]> extends IndexableObject
? Selection[K] extends SelectionOfUnknown<ArrayType<Source[K]>>
? ArrayType<Source[K]> extends IndexableObject
? Array<Select<ArrayType<Source[K]>, Selection[K]>>
: ArrayType<Source[K]> extends Primitive
? Array<SelectionOfUnknown<ArrayType<Source[K]>>>
: never
: never
: never
: Source extends IndexableObject
? Selection[K] extends SelectionOfUnknown<Source[K]>
? Source[K] extends IndexableObject
? Select<Source[K], Selection[K]>
: Source[K] extends Primitive
? SelectionOfUnknown<Source[K]>
: never
: never
: never;
};