Продвинутые типы TypeScript
В TypeScript есть развитая система типов. Она позволяет точно описывать сложные связи между типами.
Эти возможности помогают создавать приложения, которые надёжны, легко поддерживаются и защищены от многих ошибок типов.
Продвинутые типы в TypeScript
Эта страница даёт обзор основных продвинутых возможностей системы типов:
- Отображаемые типы (Mapped Types): преобразуют свойства существующего типа
- Условные типы (Conditional Types): выбирают тип по условию
- Шаблонные строковые типы (Template Literal Types): строят строковые типы с помощью шаблонного синтаксиса
- Служебные типы (Utility Types): встроенные помощники для частых преобразований
- Рекурсивные типы (Recursive Types): типы, которые ссылаются сами на себя, для древовидных данных
- Защитники типов и предикаты типов: проверки, которые сужают тип во время выполнения
- Вывод типов: сопоставление с образцом внутри типов с помощью
infer
Отображаемые типы
Отображаемый тип создаёт новый тип: он проходит по свойствам существующего типа и преобразует каждое из них.
Простой отображаемый тип
Один шаблон превращает каждое свойство объектного типа в новый тип. Здесь каждое свойство типа User становится boolean.
boolean.
Объявите дженерик Flags<T>, отображаемый по keyof T, интерфейс User с полями id: number, name: string и email: string, а также псевдоним UserFlags = Flags<User>; добавьте комментарий, показывающий эквивалентный объектный тип. Файл только объявляет типы и ничего не печатает.
Вывод: программа на TypeScript и захваченный вывод
// Превращаем все свойства в boolean
type Flags<T> = {
[K in keyof T]: boolean;
};
interface User {
id: number;
name: string;
email: string;
}
type UserFlags = Flags<User>;
// Эквивалентно:
// {
// id: boolean;
// name: boolean;
// email: boolean;
// }
Модификаторы отображаемых типов
Во время отображения можно добавлять или удалять модификаторы readonly и ? (необязательное свойство) у каждого ключа. Знак + добавляет модификатор, а - удаляет его.
readonly и ?.
Объявите интерфейс Todo (title: string, description: string, completed: boolean), отображаемый тип OptionalTodo, который делает каждое свойство необязательным, дженерик Concrete<T>, который снимает и readonly, и ? с помощью -readonly и -?, а также ReadonlyRequired<T>, который добавляет +readonly и удаляет ?. Только объявления типов, без вывода.
Вывод: программа на TypeScript и захваченный вывод
// Делаем все свойства необязательными
interface Todo {
title: string;
description: string;
completed: boolean;
}
type OptionalTodo = {
[K in keyof Todo]?: Todo[K];
};
// Удаляем модификаторы 'readonly' и '?'
type Concrete<T> = {
-readonly [K in keyof T]-?: T[K];
};
// Делаем все свойства 'readonly' и обязательными
type ReadonlyRequired<T> = {
+readonly [K in keyof T]-?: T[K];
};
Переименование ключей
С помощью конструкции as можно переименовывать ключи во время отображения или отфильтровывать их, сопоставляя с never. Это становится возможным благодаря строковым помощникам вроде Capitalize и условным проверкам.
as.
Объявите интерфейс User (id: number, name: string, email: string), дженерик Getters<T>, который переименовывает каждый ключ в get плюс имя ключа с заглавной буквы (используя Capitalize<string & K>) со значением () => T[K], псевдоним UserGetters = Getters<User> с комментарием, показывающим получившуюся форму, и тип MethodsOnly<T>, который оставляет только ключи, чьи значения расширяют Function, сопоставляя остальные с never. Только типы, ничего не печатается.
Вывод: программа на TypeScript и захваченный вывод
interface User {
id: number;
name: string;
email: string;
}
// Добавляем приставку ко всем именам свойств
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// }
// Отфильтровываем свойства
type MethodsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
Условные типы
Условный тип выбирает один из двух типов в зависимости от условия. Он работает как оператор if, но на уровне типов.
Простые условные типы
Форма T extends U ? X : Y выбирает X, когда T совместим с U, и Y в противном случае. Проверка целиком происходит на уровне типов.
infer.
Объявите IsString<T> как T extends string ? true : false, затем псевдонимы A, B, C, D для string, number, литерала 'hello' и string | number с комментариями, отмечающими результаты true, false, true и boolean. Также объявите ArrayElement<T>, который выводит тип элемента U типа массива, и псевдоним Numbers = ArrayElement<number[]>. Только типы, без вывода.
Вывод: программа на TypeScript и захваченный вывод
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<'hello'>; // true
type D = IsString<string | number>; // boolean
// Извлекаем тип элемента массива
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type Numbers = ArrayElement<number[]>; // number
Ключевое слово infer
Внутри условия условного типа infer вводит новую типовую переменную и захватывает часть проверяемого типа. Именно так работают встроенные помощники ReturnType и Parameters.
infer захватывает части типа внутри условного типа, воссоздав четыре встроенных помощника.
Объявите ReturnType<T> (выводит возвращаемый тип R типа функции, иначе any), Parameters<T> (выводит кортеж параметров P, иначе never), ConstructorParameters<T>, ограниченный типами конструкторов и выводящий кортеж параметров конструктора, и InstanceType<T>, выводящий тип экземпляра конструктора. Только типы, ничего не печатается; сделайте файл модулем, чтобы имена не конфликтовали со встроенными глобальными.
Вывод: программа на TypeScript и захваченный вывод
// Получаем возвращаемый тип функции
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
// Получаем типы параметров в виде кортежа
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
// Получаем типы параметров конструктора
type ConstructorParameters<T extends new (...args: any) => any> =
T extends new (...args: infer P) => any ? P : never;
// Получаем тип экземпляра из конструктора
type InstanceType<T extends new (...args: any) => any> =
T extends new (...args: any) => infer R ? R : any;
// Делаем этот файл модулем, чтобы имена типов не конфликтовали со встроенными глобальными
export {};
Распределение условных типов
Когда проверяемый тип — это просто параметр типа, условный тип применяется к каждому члену объединения по отдельности. Это называется распределением. Если обернуть тип в кортеж, как в [T] extends [any], распределение прекращается и объединение обрабатывается как один тип.
ToArrayNonDist<T> как T extends any ? T[] : never и StrOrNumArr = ToArrayNonDist<string | number>, затем ToArray<T>, записанный с обёрткой-кортежем [T] extends [any] ? T[] : never, и StrOrNumArr2 = ToArray<string | number>, каждый с комментарием, показывающим получившийся тип. Наконец объявите FilterStrings<T>, который оставляет только строковые члены, и псевдоним Letters, применённый к 'a' | 'b' | 1 | 2 | 'c'. Только типы, без вывода.
Вывод: программа на TypeScript и захваченный вывод
// Без распределения
type ToArrayNonDist<T> = T extends any ? T[] : never;
type StrOrNumArr = ToArrayNonDist<string | number>; // (string | number)[]
// С распределением
type ToArray<T> = [T] extends [any] ? T[] : never;
type StrOrNumArr2 = ToArray<string | number>; // string[] | number[]
// Отфильтровываем нестроковые типы
type FilterStrings<T> = T extends string ? T : never;
type Letters = FilterStrings<'a' | 'b' | 1 | 2 | 'c'>; // 'a' | 'b' | 'c'
Шаблонные строковые типы
Шаблонные строковые типы строят новые строковые типы с помощью того же синтаксиса обратных кавычек, что и шаблонные строки JavaScript.
Простые шаблонные строковые типы
Шаблонный строковый тип ограничивает строку определённым образцом. В сочетании с объединениями он порождает все возможные комбинации частей. Присваивание строки, которая не соответствует образцу, — это ошибка компиляции.
Greeting, равный шаблону `Hello, ${string}`, присвойте корректное значение 'Hello, World!' переменной validGreeting и присвойте 'Hi there!' переменной invalidGreeting с пометкой // Error, чтобы файл намеренно не прошёл проверку типов. Затем объявите объединения Color ('red' | 'green' | 'blue') и Size ('small' | 'medium' | 'large') и шаблонный тип Style вида `${Color}-${Size}` с комментарием, перечисляющим девять получившихся членов. Ожидаемый результат: ошибка компиляции для некорректного приветствия.
Вывод: программа на TypeScript и захваченный вывод
type Greeting = `Hello, ${string}`;
const validGreeting: Greeting = 'Hello, World!';
const invalidGreeting: Greeting = 'Hi there!'; // Ошибка
// С объединениями
type Color = 'red' | 'green' | 'blue';
type Size = 'small' | 'medium' | 'large';
type Style = `${Color}-${Size}`;
// 'red-small' | 'red-medium' | 'red-large' |
// 'green-small' | 'green-medium' | 'green-large' |
// 'blue-small' | 'blue-medium' | 'blue-large'
Типы для работы со строками
В TypeScript есть четыре помощника, которые меняют регистр строковых литеральных типов: Uppercase, Lowercase, Capitalize и Uncapitalize. Они удобны внутри шаблонных строковых типов.
T1 до T4, используя Uppercase<'hello'>, Lowercase<'WORLD'>, Capitalize<'typescript'> и Uncapitalize<'TypeScript'>, с комментариями, показывающими результаты, затем объединение EventType ('click' | 'change' | 'keydown') и шаблонный тип EventHandler, который добавляет приставку on к именам событий с заглавной буквы. Только типы, без вывода.
Вывод: программа на TypeScript и захваченный вывод
// Встроенные типы для работы со строками
type T1 = Uppercase<'hello'>; // 'HELLO'
type T2 = Lowercase<'WORLD'>; // 'world'
type T3 = Capitalize<'typescript'>; // 'Typescript'
type T4 = Uncapitalize<'TypeScript'>; // 'typeScript'
// Создаём тип обработчика событий
type EventType = 'click' | 'change' | 'keydown';
type EventHandler = `on${Capitalize<EventType>}`;
// 'onClick' | 'onChange' | 'onKeydown'
Продвинутые приёмы
Шаблонные строковые типы можно сочетать с infer и переименованием ключей. Это позволяет извлекать информацию из строкового типа, например параметры маршрута, или создавать целый набор имён обработчиков из карты событий.
infer и переименование ключей.
Объявите рекурсивный тип ExtractRouteParams<T>, который сопоставляется с `${string}:${infer Param}/${infer Rest}` (и с формой для одного параметра), чтобы построить объектный тип с одним свойством string на каждый параметр маршрута, а также псевдоним Params для '/users/:userId/posts/:postId'. Затем объявите тип EventMap (click с числами x/y, change как string, keydown с key: string и code: number) и отображаемый тип EventHandlers, который переименовывает каждый ключ в on плюс имя с заглавной буквы и типизирует его как обработчик, принимающий EventMap[K]. Только типы, без вывода.
Вывод: программа на TypeScript и захваченный вывод
// Извлекаем параметры маршрута
type ExtractRouteParams<T> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<`${Rest}`>]: string }
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type Params = ExtractRouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string; }
// Создаём типобезопасный генератор событий
type EventMap = {
click: { x: number; y: number };
change: string;
keydown: { key: string; code: number };
};
type EventHandlers = {
[K in keyof EventMap as `on${Capitalize<K>}`]: (event: EventMap[K]) => void;
};
Служебные типы
В TypeScript есть много встроенных служебных типов. Они покрывают самые частые преобразования типов, поэтому вам не нужно писать их самостоятельно.
Распространённые служебные типы
Partial, Required, Readonly, Pick и Omit — те, которыми вы будете пользоваться чаще всего. Также можно получить тип одного свойства через User['id'] и все имена ключей через keyof User.
User с полями id: number, name: string, email: string и createdAt: Date, затем псевдонимы PartialUser = Partial<User>, RequiredUser = Required<PartialUser>, ReadonlyUser = Readonly<User>, UserPreview = Pick<User, 'id' | 'name'> и UserWithoutEmail = Omit<User, 'email'>, каждый с коротким комментарием. Завершите типами UserId = User['id'] и UserKeys = keyof User с комментариями, показывающими результаты. Только типы, без вывода.
Вывод: программа на TypeScript и захваченный вывод
// Базовые типы
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
// Делаем все свойства необязательными
type PartialUser = Partial<User>;
// делаем все свойства обязательными
type RequiredUser = Required<PartialUser>;
// делаем все свойства только для чтения
type ReadonlyUser = Readonly<User>;
// выбираем определённые свойства
type UserPreview = Pick<User, 'id' | 'name'>;
// исключаем определённые свойства
type UserWithoutEmail = Omit<User, 'email'>;
// извлекаем типы свойств
type UserId = User['id']; // number
type UserKeys = keyof User; // 'id' | 'name' | 'email' | 'createdAt'
Продвинутые служебные типы
Exclude убирает члены из объединения, а Extract оставляет только подходящие. Можно также писать собственные помощники, например тип Mutable, который снимает readonly со всех свойств.
NonNullable<T>, который сопоставляет null | undefined с never, объединение Numbers вида 1 | 2 | 3 | 'a' | 'b', типы JustNumbers = Exclude<Numbers, string> и JustStrings = Extract<Numbers, string> с комментариями о результатах, затем объектные типы A (a: string; b: number; c: boolean) и B (a: string; b: number) и C = Omit<A, keyof B>, и наконец отображаемый тип Mutable<T>, использующий -readonly. Только типы, без вывода; сделайте файл модулем, чтобы NonNullable не конфликтовал с глобальным типом.
Вывод: программа на TypeScript и захваченный вывод
// Создаём тип, который исключает null и undefined
type NonNullable<T> = T extends null | undefined ? never : T;
// Исключаем типы из объединения
type Numbers = 1 | 2 | 3 | 'a' | 'b';
type JustNumbers = Exclude<Numbers, string>; // 1 | 2 | 3
// Извлекаем типы из объединения
type JustStrings = Extract<Numbers, string>; // 'a' | 'b'
// Получаем тип, которого нет во втором типе
type A = { a: string; b: number; c: boolean };
type B = { a: string; b: number };
type C = Omit<A, keyof B>; // { c: boolean }
// Создаём тип, у которого все свойства изменяемые
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Делаем этот файл модулем, чтобы имена типов не конфликтовали со встроенными глобальными
export {};
Рекурсивные типы
Рекурсивный тип ссылается сам на себя в собственном определении. Это естественный способ описать древовидные данные, где узел может содержать другие узлы такой же формы.
Простой рекурсивный тип
Узел двоичного дерева содержит необязательные узлы left и right того же типа. Значение JSON может содержать массивы и объекты из других значений JSON. Комментарий содержит массив ответов, которые тоже являются комментариями.
BinaryTree<T> со значением value и необязательными поддеревьями left/right того же типа, объединение JSONValue из string, number, boolean, null, JSONValue[] и объекта со значениями JSONValue, а также тип Comment с полями id, content, массивом replies: Comment[] и createdAt: Date. Только типы, без вывода; сделайте файл модулем, чтобы Comment не конфликтовал с глобальным типом DOM.
Вывод: программа на TypeScript и захваченный вывод
// Простое двоичное дерево
type BinaryTree<T> = {
value: T;
left?: BinaryTree<T>;
right?: BinaryTree<T>;
};
// Структура данных наподобие JSON
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
// Вложенные комментарии
type Comment = {
id: number;
content: string;
replies: Comment[];
createdAt: Date;
};
// Делаем этот файл модулем, чтобы имена типов не конфликтовали со встроенными глобальными
export {};
Продвинутые рекурсивные типы
Та же идея описывает связный список, дерево каталогов, потомки которого — файлы или другие каталоги, конечный автомат, переходы которого ведут к другим состояниям, и даже тип функции, которая принимает саму себя.
LinkedList<T> с полями value и next: LinkedList<T> | null, тип File (type: 'file', name, size) и тип Directory (type: 'directory', name, children: (File | Directory)[]), тип State, у которого transitions сопоставляет имена событий другим состояниям State, и тип функции RecursiveFunction<T>, которая принимает T | RecursiveFunction<T>. Только типы, без вывода; сделайте файл модулем, чтобы File не конфликтовал с глобальным типом DOM.
Вывод: программа на TypeScript и захваченный вывод
// Тип для связного списка
type LinkedList<T> = {
value: T;
next: LinkedList<T> | null;
};
// Тип для структуры каталогов
type File = {
type: 'file';
name: string;
size: number;
};
type Directory = {
type: 'directory';
name: string;
children: (File | Directory)[];
};
// Тип для конечного автомата
type State = {
value: string;
transitions: {
[event: string]: State;
};
};
// Тип для рекурсивной функции
type RecursiveFunction<T> = (x: T | RecursiveFunction<T>) => void;
// Делаем этот файл модулем, чтобы имена типов не конфликтовали со встроенными глобальными
export {};
Рекомендации
Когда использовать продвинутые типы:
- Используйте отображаемые типы, когда нужно изменить сразу много свойств объектного типа
- Используйте условные типы, когда один тип зависит от другого типа
- Используйте шаблонные строковые типы для строковых образцов и сопоставления строк
- Используйте служебные типы для частых преобразований — предпочитайте встроенные, когда они подходят
- Используйте рекурсивные типы для древовидных или вложенных данных
Что учитывать для производительности:
- Глубоко вложенные рекурсивные типы могут замедлить компилятор TypeScript
- Очень большие объединения (100+ членов) могут вызвать проблемы с производительностью
- Разбивайте сложные типы на небольшие псевдонимы типов
Частые ошибки
Сложности с выводом типов:
- Условные типы распределяются по объединениям, что бывает неожиданно
inferведёт себя по-разному в зависимости от того, где он используется- Некоторые служебные типы плохо работают с
anyилиunknown
Удобство поддержки:
- Избыток сложных типов делает код трудным для чтения
- Поясняйте сложные преобразования типов комментариями
- Для очень сложных случаев проще может оказаться утверждение типа или вспомогательная функция