Продвинутые типы, идиомы и обработка ошибок в TypeScript
В прошлом файле мы разобрали ядро системы типов. Теперь — «система типов как язык
программирования»: условные и отображаемые типы, infer, шаблонные строковые
типы. Затем спустимся на землю и разберём идиомы: satisfies, рантайм-валидацию
через Zod и то, как в TypeScript правильно обрабатывать ошибки (спойлер: throw
не типизирован, и это меняет подход).
Conditional types — типы, которые ветвятся
Условный тип выбирает результат по проверке extends. Синтаксис зеркалит
тернарный оператор, но работает на уровне типов:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
Условные типы дистрибутивны над union: применяются к каждому члену объединения по отдельности. Это тонкость, которая объясняет много «странного»:
type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>; // string[] | number[], а НЕ (string | number)[]
Так реализованы встроенные Exclude, Extract, NonNullable:
type MyExclude<T, U> = T extends U ? never : T; // убрать U из union T
type MyNonNullable<T> = T extends null | undefined ? never : T;
type X = MyExclude<"a" | "b" | "c", "b">; // "a" | "c"
infer — извлечение типов из структур
infer объявляет «дырку» внутри условного типа, куда компилятор выведет
подтип. Это как деструктуризация, но для типов. Именно так устроены ReturnType,
Parameters, Awaited:
// Извлекаем возвращаемый тип функции
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = () => Promise<number>;
type R = MyReturnType<Fn>; // Promise<number>
// Извлекаем тип элемента массива
type ElementType<T> = T extends readonly (infer E)[] ? E : never;
type E = ElementType<string[]>; // string
// Рекурсивно «разворачиваем» Promise (как встроенный Awaited)
type Unwrap<T> = T extends Promise<infer Inner> ? Unwrap<Inner> : T;
type U = Unwrap<Promise<Promise<boolean>>>; // boolean
infer — ключ к типобезопасной работе с чужими сигнатурами: вы можете «вытащить»
тип из библиотечной функции, не переписывая его руками.
Mapped types — трансформация каждого поля
Отображаемый тип пробегает по ключам и строит новый тип. Это то, из чего сделаны
Partial, Readonly, Pick:
// Реализация встроенного Partial
type MyPartial<T> = {
[K in keyof T]?: T[K]; // по каждому ключу K делаем поле опциональным
};
// Снять readonly и опциональность (модификаторы можно добавлять и убирать)
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K]; // минус убирает модификатор
};
Мощь раскрывается с key remapping через as и шаблонными типами — можно
переименовывать ключи:
// Сгенерировать геттеры из полей: { name: string } -> { getName: () => string }
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }
Template literal types — типы из строк
Шаблонные строковые типы позволяют конструировать и парсить строковые типы. Это не просто трюк — с их помощью типизируют роуты, CSS-свойства, ключи событий:
type Event = "click" | "hover";
type Handler = `on${Capitalize<Event>}`; // "onClick" | "onHover"
// Парсинг route-параметров из строки пути
type RouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & RouteParams<Rest>
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type Params = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }
Встроенные Uppercase, Lowercase, Capitalize, Uncapitalize дополняют
арсенал. Комбинация template literals + infer + рекурсии превращает систему
типов в полноценный язык (см. type-challenges).
Важное предостережение: «типы ради типов» — антипаттерн. Сложный тип, который никто в команде не может прочитать, — это долг. Пишите ровно столько типовой магии, сколько окупается безопасностью и автодополнением на границах.
satisfies — проверить, не расширяя
Оператор satisfies (TS 4.9+) решает давнюю дилемму: как проверить, что значение
соответствует типу, не заменяя выведенный узкий тип этим широким?
// Проблема с аннотацией: теряем узость
const routes1: Record<string, string> = {
home: "/",
about: "/about",
};
// routes1.home имеет тип string, а не "/" — узость потеряна
// satisfies: проверяет соответствие, но сохраняет точный выведенный тип
const routes2 = {
home: "/",
about: "/about",
} satisfies Record<string, string>;
// routes2.home имеет тип "/", при этом опечатку в значении компилятор поймал бы
const color = { r: 255, g: 0, b: 0 } satisfies Record<string, number>;
color.r.toFixed(); // OK: r известен как number, и ключ проверен
Правило: satisfies для констант-конфигов, где нужно и проверить форму, и
сохранить литеральную точность. Аннотация : Type — когда точность не нужна.
as Type — крайняя мера (обход проверки).
Идиоматичная обработка ошибок
Здесь TypeScript принципиально отличается от Java/C#: throw не типизирован.
Функция не объявляет, что она бросает; в catch вы получаете unknown (при
включённом useUnknownInCatchVariables, входит в strict). Язык не заставляет
обрабатывать исключения. Отсюда две школы.
Подход 1: исключения (идиоматичный «по умолчанию»)
Бросайте Error (или его подклассы), ловите на нужном уровне. В catch
обязательно сужайте unknown:
class NotFoundError extends Error {
readonly code = "NOT_FOUND" as const;
constructor(public readonly id: string) {
super(`Не найдено: ${id}`);
this.name = "NotFoundError";
}
}
async function loadUser(id: string): Promise<User> {
const user = await db.users.find(id);
if (!user) throw new NotFoundError(id);
return user;
}
try {
await loadUser("u_1");
} catch (err) {
// err имеет тип unknown — сужаем перед использованием
if (err instanceof NotFoundError) {
console.warn(err.id);
} else if (err instanceof Error) {
console.error(err.message);
} else {
console.error("Неизвестная ошибка", err);
}
}
Всегда наследуйте от Error (иначе теряются стектрейс и instanceof) и никогда
не бросайте строки. Классы ошибок с полем-дискриминатором (code) образуют
удобный discriminated union для централизованной обработки.
Подход 2: Result-тип (ошибки как значения)
Для ожидаемых доменных ошибок (валидация, «не найдено», «недостаточно средств») удобнее возвращать их как значения — по образцу Rust. Тогда компилятор заставляет обработать ошибку, потому что она в типе возврата:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type PurchaseError = "INSUFFICIENT_FUNDS" | "OUT_OF_STOCK";
function purchase(balance: number, price: number): Result<number, PurchaseError> {
if (balance < price) return { ok: false, error: "INSUFFICIENT_FUNDS" };
return { ok: true, value: balance - price };
}
const res = purchase(100, 150);
if (res.ok) {
console.log("Остаток:", res.value); // value доступен только в ветке ok
} else {
console.log("Отказ:", res.error); // error доступен только в ветке !ok
}
Это тот же discriminated union из прошлого файла. Практическое правило: Result
для ожидаемых, восстановимых ошибок; исключения — для непредвиденных/фатальных
(баг, недоступная БД). Библиотеки вроде neverthrow
дают готовый Result с цепочками методов.
Типобезопасность на границах: рантайм-валидация через Zod
Ключевая мысль всего курса: типы стираются, внешним данным верить нельзя.
JSON.parse возвращает any, тело HTTP-запроса — это чужие байты. Аннотация
const user = body as User — ложь компилятору: если сервер прислал не то,
рантайм упадёт позже и в неожиданном месте.
Решение — валидация на границе библиотекой Zod: одна схема служит и рантайм-проверкой, и источником типа.
import { z } from "zod";
// Схема — единый источник правды
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
// Тип ВЫВОДИТСЯ из схемы — не дублируем руками
type User = z.infer<typeof UserSchema>;
// { id: string; email: string; age?: number }
function parseUser(input: unknown): User {
// parse бросит ZodError, если данные не соответствуют; иначе вернёт типизованный User
return UserSchema.parse(input);
}
// safeParse — без исключений, в стиле Result
const result = UserSchema.safeParse(await request.json());
if (!result.success) {
return reply.code(400).send({ errors: result.error.issues });
}
const user = result.data; // здесь user гарантированно валиден и типизован
Валидируйте всё, что пересекает границу процесса: HTTP-запросы и ответы, env- переменные, сообщения очередей, содержимое файлов, ответы сторонних API. Внутри кода, после границы, доверяйте типам. Это и есть «unknown на входе — точный тип на выходе». Альтернативы Zod: Valibot (легче по весу), ArkType, io-ts.
Пример для env — валидируйте конфигурацию при старте, чтобы упасть сразу, а не в рантайме:
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().default(3000), // coerce: "3000" -> 3000
DATABASE_URL: z.string().url(),
});
export const env = EnvSchema.parse(process.env); // упадёт при старте, если конфиг кривой
Идиомы и антипаттерны
- Immutability по умолчанию. Предпочитайте
readonly,const, spread вместо мутаций. Меньше классов багов, лучше для конкурентности. - Узкие типы на входе, широкие на выходе — наоборот. Функция должна требовать
минимум (широкий приём,
readonly T[]) и обещать максимум точности на выходе. - Избегайте
asи!(non-null assertion). Оба обходят проверку. Каждое использование — потенциальная дыра; помечайте комментарием, почему это безопасно. - Не типизируйте
Functionиobject. Это почти-any. Указывайте конкретную сигнатуру функции и форму объекта. - Не дублируйте типы и рантайм. Если есть Zod-схема — выводите тип из неё
(
z.infer), не пишитеinterfaceрядом. - DRY на уровне типов. Выводите производные типы (
Pick,Omit,ReturnType) вместо копипасты полей.
Источники
- Handbook: Conditional Types, Mapped Types, Template Literal Types.
- Matt Pocock, Total TypeScript — раздел про
satisfiesи трансформации типов. - Zod документация — валидация и вывод типов.
- type-challenges — тренировка на conditional/mapped/infer.
Что дальше
Типы освоены. Переходим к тому, как TypeScript исполняется во времени — модели конкурентности, event loop и асинхронности.