Creating a Subset Type in TypeScript
TypeScript was designed to formally describe types in any JavaScript code. Because of this, its type system supports much more than a type system of a typical objected oriented language does. Let's say I want to create a type that consists of a subset of members of another type. There are multiple ways how to achieve that.
Taking Advantage of Structural Typing
Type compatibility in TypeScript is based on structural typing. It depends only on the members of the types involved. How the types are related to each other doesn't have any effect. Because of this, I can create a subset type by simply duplicating the members I want it to have:
export interface Book {
/** Title of the book */
title: string;
/** Author of the book */
author: string;
/** Book's ISBN13 number */
ISBN13: string;
/** Publisher of the book */
publisher: string;
/** Number of pages in the book */
pages: number;
}
export interface BookCover {
/** Title of the book */
title: string;
/** Author of the book */
author: string;
}
This simple approach has two important downsides:
- Along with duplicated members, I must also copy the JSDoc comments. If I don't do that, the subset type won't have any documentation for its members.
- The compiler doesn't know that I want the types to be related. If the copied members (or their JSDoc) change in the full type, I need to manually update the subset type.
The Object-Oriented Way
Object-oriented languages don't allow structural typing. The types must be related to be considered compatible. To create a subset type, type inheritance must be used:
export interface BookCover {
/** Title of the book */
title: string;
/** Author of the book */
author: string;
}
export interface Book extends BookCover {
/** Book's ISBN13 number */
ISBN13: string;
/** Publisher of the book */
publisher: string;
/** Number of pages in the book */
pages: number;
}
This gets rid of duplicate member definitions but requires the full type to be aware of the subset type. This introduces different downsides:
- For the full type to extend the subset type, we need control over the full type. This won't be the case if the full type was introduced in an external library or was generated (e.g. from an OpenAPI description of a REST service).
- The approach becomes difficult to manage if we want to have multiple different subset types for the same full type.
Using Mapped Types
The final approach involves using mapped types to transform members of an existing type:
export interface Book {
/** Title of the book */
title: string;
/** Author of the book */
author: string;
/** Book's ISBN13 number */
ISBN13: string;
/** Publisher of the book */
publisher: string;
/** Number of pages in the book */
pages: number;
}
export type BookCover = Pick<Book, 'title' | 'author'>;
Although the second generic argument of the Pick
type involves strings, they are still type-safe. The compiler will emit an error if there's no member with such a name. So, if a change in the full type affects the subset type, the compiler will tell me about it. I also don't need control over the full type, nor do I have to duplicate the definitions. This approach combines the best parts of the other two approaches.