Conditional и Mapped Types в TypeScript. По-настоящему гибкая типизация

Frontend

Если даже TypeScript и задумывался просто как «надмножество с типами» над нашим родным JS, то с развитием стал чем-то большим. В арсенале разработчика оказались многие инструменты, позволяющие типам и адаптироваться под логику приложения. Сегодня поговорим про два мощнейших приема, которыми стоит овладеть: Conditional Types (условные типы) и Mapped Types (хорошего перевода не встречал, но самое адекватное и близкое по смыслу — отображенные типы).

1. Conditional Types — if для типов

Условные типы позволяют выбирать тип на основе проверки. Синтаксис похож на тернарный оператор в JS:

type Conditional<T> = T extends U ? X : Y

Если T можно присвоить типу U, то результирующий тип Conditional — X, иначе — Y. Естественно, U, X, Y нужно заменить на какие-то реальные типы. Разберем простой пример и станет понятно.

Представим функцию, которая возвращает длину массива или строки. Мы хотим, чтобы для строки и массива мы возвращали число, а для любых других данных — ошибку. Тогда напишем так.

type LengthOrNever<T> = T extends string | any[] ? number : never;

function getLength<T>(value: T): LengthOrNever<T> {
  if (typeof value === "string") return value.length as any;

  if (Array.isArray(value)) return value.length as any;

  throw new Error("Не поддерживается");
}

const len1 = getLength("hello"); // number
const len2 = getLength([1, 2, 3]); // number
const len3 = getLength(123); // never – такой вызов TS подсветит как ошибку

Чуть менее высосанный из пальца пример — фильтрация юнион-типа. Например, какие-то бэкендеры и фронтендеры, которые работали на проекте до нас, описали что с бэка помимо валидных значений в типе товара может прийти что угодно. Но где-то в коде нам нужно взять только осмысленные значения, отсеяв весь шлак. Условные типы тут как раз к месту.

type ProductTypesAPI = "a" | "b" | undefined | 0 | boolean | null;

type OnlyValidValues<T> = T extends string ? T : never;

type ProductTypes = OnlyValidValues<ProductTypesAPI>; // "a" | "b"

2. Mapped Types — трансформация свойств

Mapped Types позволяют пройти по всем ключам существующего типа и создать новый. Синтаксис напоминает Array.map() для типов.

Если вы используете встроенные ютилити-тайпы, то значит и с маппингом уже сталкивались. Ведь, например, те же Readonly<T> и Partial<T> окажутся именно отраженными типами, если заглянуть в исходный код.


type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

Но чтобы понять как оно работает на самом деле, нужно написать что-то свое. Представим, что у нас теперь задача обратная тому, что мы делали в последнем примере. У нас на фронте есть прекрасный, чистенький интерфейс с описанием типа пользователя, но с бэкенда, в каждом поле может прийти еще и null. Чтобы не писать руками, используем маппинг.


type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

interface User {
  id: number;
  name: string;
  email: string;
}

type UserWithNullable = Nullable<User>;
// { id: number | null; name: string | null; email: string | null; }

Чтобы понять что здесь произошло, разберем пример по косточкам

  • keyof T вернул нам юнион-тип, состоящий из ключей переданного типа (в данном случае — id | name | email)
  • Мы проходимся по этому юниону через P in keyof T, то есть на каждой итерации P равен очередному ключу переданного типа
  • Для каждого ключа мы устанавливаем новый тип, который равен либо исходному T[P], либо null

Точно так же можно пройтись по типу и добавить или снять какие-то модификаторы. Для снятия просто используем префикс -.

// Делаем поля мутабельными, снимая с них модификатор readonly

type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

type ImmutableConfig = {
  readonly apiUrl: string;
  readonly timeout: number;
};

type EditableConfig = Mutable<ImmutableConfig>;
// Представим что из TS пропал Required и напишем свой

type MyRequired<T> = {
  [P in keyof T]-?: T[P];
};

type PartialUser = {
  id?: number;
  name?: string;
  email?: string;
}

type ReuiredUser = MyRequired<PartialUser>;

Подведем итоги и пойдём работать

Conditional и Mapped Types – это не магия, а инструменты для решения ежедневных задач. Это тот TS, который мы заслужили.

Используя эти инструменты, вы создаете стройную систему типов, которая отражает бизнес-логику приложения. Ваш код становится самодокументируемым, а возможные ошибки отлавливаются на этапе написания (иногда даже на этапе написания типов), а не стреляют на проде вечером пятницы.

Симо Мофин
Симо Мофин

Senior Frontend Developer
Главный по блогу