Программирование на типах · фундамент
Этот урок переключает регистр. До сих пор мы относились к типам как к аннотациям. Пишешь name: string, чтобы компилятор не дал по рукам. На самом деле система типов TypeScript это полноценный язык программирования. В нём есть переменные (параметры дженериков), функции (сами дженерики), значения (типы), управляющие конструкции (условные типы, появятся в уроке 03), циклы (рекурсия и mapped-типы) и сопоставление с образцом (infer).
Цель урока: дать тебе словарь и грамматику этого языка. После него ты должен уметь читать сложные типы из библиотек (fp-ts, effect, zod), писать свои утилитные типы руками и понимать, что значит “Pick раскручивается в mapped-тип”. Опираемся на бесплатные главы 1-4 курса Габриэля Веньо type-level-typescript.com. Я считаю его лучшим обучающим ресурсом по теме. Каждая секция ниже даёт прямую ссылку на главу, где автор копает глубже.
Сквозной артефакт серии, типобезопасная функция navigate(), рождается тут. В уроке мы сделаем её упрощённый вариант с одним параметром и плоским роутом. Полная версия с шаблонными литералами и рекурсией ждёт в уроке 03.
1. Типы это значения
Главный сдвиг: типы существуют в собственном измерении, параллельном времени выполнения. У них есть форма, есть правила композиции, и компилятор это полноценный интерпретатор для них.
// 1. Объявление типа = объявление переменной типового мира
type Greeting = 'hello';
// 2. Дженерик-функция = функция типового мира
type Wrap<T> = { value: T };
// 3. Применение = вызов
type Wrapped = Wrap<Greeting>; // { value: 'hello' }
Параллель со временем выполнения буквальная.
// время выполнения
const greeting = 'hello';
const wrap = <T>(value: T) => ({ value });
const wrapped = wrap(greeting);
Когда ты пишешь Wrap<Greeting>, компилятор подставляет T = Greeting и редуцирует выражение, как обычная функция редуцирует аргументы. Вся “магия” программирования на типах это только применение этой аналогии в больших масштабах.
Дженерик-функцию можно протипизировать так, чтобы её результат зависел от входа.
type Pair<A, B> = [A, B];
type IntStrPair = Pair<number, string>; // [number, string]
Это уже простейшая функция в типовом мире. В уроке 03 мы научимся писать ветвления (A extends B ? X : Y) и рекурсию.
Канонический пример из главы 1 курса type-level-typescript: безопасный call для пары “функция, аргументы”. Без дженериков такой штуке пришлось бы вернуть unknown. С дженериками результат точный.
function call<T extends (...args: never[]) => unknown>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
return fn(...args) as ReturnType<T>;
}
const r = call((x: number, y: number) => x + y, 1, 2); // r: number
Parameters<T> и ReturnType<T> это встроенные утилитные типы, и сами они уже функции в типовом мире. Берут на вход тип функции, возвращают кортеж аргументов или возвращаемый тип.
Глава: type-level-typescript.com/types-and-values.
2. Типы как данные
Чтобы программировать на типах, надо смириться с тем, что типы это данные. У каждого типа есть конкретное “значение” в типовом мире, и с ним можно делать операции: складывать в объединение (A | B), пересекать (A & B), индексировать (T[K]), мапить ({[K in keyof T]: ...}).
Примитивные типы. string, number, bigint, boolean, null, undefined, void, symbol. Каждый из них это бесконечное объединение литералов: string это 'a' | 'b' | ... | 'aa' | ..., просто синтаксически бесконечное.
Литеральные типы. 'hello', 42, true. Они подмножества своих примитивных. 'hello' extends string всегда true. Литералы это самый узкий способ описать тип; они появляются на каждом шагу типового кода.
Верхний тип unknown. Все типы совместимы с unknown. Достать из unknown нельзя ничего без сужения.
Нижний тип never. В never нельзя положить ничего, но он совместим с любым типом. Это пустое объединение.
any. Особый тип, выключающий проверку. Не верх, не низ, не ничего; стоит сбоку. В типовом коде встречается редко, и хорошо, что редко.
void. Ровно один смысл: возврат функции, чей результат игнорируется. Технически в неё можно вернуть что угодно (void особый), и это удобно в callback-ах. Array.prototype.forEach объявлен как (cb: (x: T) => void) => void, и ты можешь передать любую функцию, не задумываясь о возвращаемом значении.
const arr = [1, 2, 3];
arr.forEach((x) => x.toFixed()); // ок: возвращаем строку, void проглотит
Глава: type-level-typescript.com/types-are-just-data.
3. Объекты и записи
keyof T возвращает объединение ключей объекта. На простых объектах это объединение строковых литералов; на объектах с индексной сигнатурой это string, number или symbol.
type User = { id: string; name: string; email: string };
type UserKey = keyof User; // 'id' | 'name' | 'email'
Индексный доступ T[K]. На типах это похоже на obj[key] во время выполнения, только результат тип, а не значение.
type UserName = User['name']; // string
type UserField = User[keyof User]; // string (объединение всех значений)
Record<K, V> это объект, у которого все ключи K имеют значение типа V.
type StatusMap = Record<'idle' | 'ready' | 'error', boolean>;
// { idle: boolean; ready: boolean; error: boolean }
Record определяется как mapped-тип: Record<K, V> = { [P in K]: V }.
Mapped-типы это основа программирования на типах над объектами. Идея простая: проходим по ключам и формируем новое значение.
// все поля Optional
type MyPartial<T> = { [P in keyof T]?: T[P] };
// все поля Readonly
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };
// инвертировать ключи (невалидный TS, но идея ясна)
// type Invert<T> = { [P in T[keyof T]]: ??? }
// (полное решение через as-clause в уроке 03)
Pick<T, K> и Omit<T, K> строятся ровно так.
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyOmit<T, K extends keyof T> = { [P in Exclude<keyof T, K>]: T[P] };
Exclude<U, X> фильтрует объединение: убирает из U все типы, которые совместимы с X. Реализуется через условные типы (урок 03).
Открытые объекты против закрытых. Типы объектов в TypeScript “открытые” по умолчанию: { name: string } совместим с { name: string; age: number }. Проверка лишних свойств (урок 01) ловит лишние поля только в литералах. Если хочется по-настоящему закрытый объект, который запрещает любые лишние свойства, чистого решения в TypeScript нет; есть обходы через XOR или Strict<T> (см. главу 3 курса), но они шумные.
Глава: type-level-typescript.com/objects-and-records.
Поиграй с виджетом ниже. Первые два примера это Pick и Partial руками. Третий, Tail, ждёт в следующей секции.
Mapped type Pick: пройти по ключам K, для каждого взять T[P].
Кнопка step добавляет следующий шаг подстановки.
prev убирает последний. reset возвращает
к началу. Тот же приём работает руками: ставишь курсор на тип,
Cmd-нажимаешь Go to definition, читаешь определение, мысленно
подставляешь параметры.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.