TypeScript Продвинутые типы, идиомы и обработка ошибок в TypeScript
0%

Продвинутые типы, идиомы и обработка ошибок в TypeScript

Продвинутые типы, идиомы и обработка ошибок в 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) вместо копипасты полей.

Источники

Что дальше

Типы освоены. Переходим к тому, как TypeScript исполняется во времени — модели конкурентности, event loop и асинхронности.

Конкурентность и асинхронность

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов