Настоящая type-safety в TypeScript: 6 практических паттернов
Во многих проектах TypeScript есть, а type-safety — нет. tsconfig на месте, файлы .ts, tsc в CI зелёный — но в каждом третьем месте кода any, ответы API «заверены» через as, а в production всё равно undefined is not a function. Я называю это «писать на TypeScript». «Пользоваться TypeScript» — другое дело: превратить компилятор в напарника, который ищет ошибки за вас. И помните: каждый раз, когда вы пишете any, вы берёте время в долг — проценты заплатите позже, в виде production-багов и страшного рефакторинга.
Ниже — 6 паттернов, которые оправдали себя в наших командах. Для каждого один и тот же формат: проблема, код решения и когда применять.
1. Discriminated union: сделать невозможные состояния невозможными
Проблема. Описывать состояние запроса отдельными полями — распространённая ошибка:
interface State {
isLoading: boolean;
data?: User[];
error?: string;
}Этот тип разрешает isLoading: true и error: "..." одновременно. Пока невозможные состояния «живут» в типе, код вынужден повсюду защищаться от них через if — и однажды кто-то забудет защиту.
Решение. Разделяем состояния общим полем status:
type State =
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; message: string };
function render(state: State) {
switch (state.status) {
case "loading":
return spinner();
case "success":
return list(state.data); // data существует только в этой ветке
case "error":
return alertBox(state.message);
default: {
// если добавят новый status, эта строка упадёт на компиляции
const _exhaustive: never = state;
return _exhaustive;
}
}
}Exhaustiveness check через never — самая дешёвая страховка. Если кто-то добавит status: "retrying", компилятор сам найдёт каждый забытый switch — представьте, каково искать их вручную.
Когда. Любое состояние с различающим полем вроде status, kind, type: жизненный цикл запроса (loading/success/error), этапы платежа, типы WebSocket-сообщений, шаги wizard-а.
2. Branded types: string — не всегда просто string
Проблема. UserId — это string, и OrderId — тоже string. Перепутаете аргументы функции — компилятор промолчит, а баг найдётся в production, когда отменится чужой заказ.
Решение. Добавляем к типу «клеймо», которое существует только на этапе компиляции:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
// помечаем один раз на границе
const toUserId = (id: string) => id as UserId;
const toOrderId = (id: string) => id as OrderId;
function cancelOrder(orderId: OrderId) { /* ... */ }
const userId = toUserId("u_1042");
cancelOrder(userId); // Ошибка: тип 'UserId' несовместим с 'OrderId'Обратите внимание: as используется ровно в одном месте — в функции-конструкторе. Весь остальной код работает с branded-типами, и перепутать их уже невозможно.
Когда. ID, токены, деньги (перепутать сумы и тийины — реальный баг!), места, где важна разница между сырым и нормализованным значением. Если два string или number с разным смыслом встречаются в одной функции — брендируйте.
3. satisfies: проверь, но сохрани точный тип
Проблема. Старые способы типизировать конфиг-объект что-то теряют: as вообще ничего не проверяет, а обычная аннотация (const c: Config = ...) «расширяет» конкретные ключи до общего типа, и autocomplete умирает.
Решение. satisfies проверяет соответствие форме, но сохраняет узкий (выведенный) тип:
type ServiceConfig = Record<string, { url: string; timeout: number }>;
// Плохо: "as" ничего не проверяет
const legacy = { billing: { url: "https://billing.internal" } } as ServiceConfig;
legacy.billing.timeout; // есть в типе, нет в значении — undefined в runtime
// Хорошо: форма проверяется, конкретные ключи сохраняются
const config = {
billing: { url: "https://billing.internal", timeout: 5000 },
auth: { url: "https://auth.internal", timeout: 3000 },
} satisfies ServiceConfig;
config.auth.timeout; // autocomplete предлагает только реальные ключиРазница проста: as — «поверь мне», satisfies — «проверь, но не забывай, что знаешь».
Когда. Конфиг-объекты, карты роутов, словари i18n, темы — везде, где нужна проверка формы, но нельзя терять autocomplete по конкретным ключам.
4. unknown vs any: на границе — не доверие, а проверка
Проблема. any — это выход из системы типов, и он заразен: каждое значение, полученное из any, которое вернул JSON.parse, — тоже any, и ошибка расползается по всему модулю. А компилятор на всё говорит «OK».
Решение. На границе вместо any — unknown: он заставляет проверить значение перед использованием:
function parseJson(text: string): unknown {
return JSON.parse(text); // намеренно делаем результат unknown
}
const data = parseJson(rawBody);
// data.email — Ошибка: 'data' is of type 'unknown'
if (
typeof data === "object" && data !== null &&
"email" in data && typeof data.email === "string"
) {
sendEmail(data.email); // внутри блока email — точно string
}any говорит «мне всё равно», unknown — «сначала докажи». В этом вся разница.
Когда. Любая внешняя граница: JSON.parse, ответ fetch, localStorage, переменные окружения, сообщение из очереди. Если ручной narrowing кажется утомительным — следующий паттерн как раз об этом.
5. Runtime-валидация на границе: zod
Проблема. const user = (await res.json()) as User — это не тип, а надежда. Типы стираются в runtime: если backend переименует поле или начнёт присылать null, as вас не защитит. «Есть тип» не значит «пришло правильное».
Решение. На границе парсим по схеме — а статический тип выводится из неё сам:
import { z } from "zod";
const OrderSchema = z.object({
id: z.string(),
amount: z.number().positive(),
status: z.enum(["pending", "paid", "cancelled"]),
});
type Order = z.infer<typeof OrderSchema>; // тип выводится из схемы
async function fetchOrder(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${id}`);
const result = OrderSchema.safeParse(await res.json());
if (!result.success) {
// контракт нарушен — не продолжаем молча, даём явную ошибку
throw new Error(`Нарушен контракт Order API: ${result.error.message}`);
}
return result.data; // теперь это действительно Order
}Из одной схемы вы получаете и runtime-проверку, и статический тип — не нужно писать их отдельно и держать в синхроне. valibot или ArkType делают то же самое: важна идея, а не библиотека.
Когда. Любой вход, который вы не контролируете: внешний API, webhook, пользовательская форма, сообщение из очереди. Между внутренними функциями не нужно — проверили один раз на границе, дальше типам можно доверять.
6. Template literal types: превратить формат строки в тип
Проблема. Имена событий подчиняются конвенции вроде "user.created", но пока их тип — просто string, опечатка "user.craeted" спокойно доживает до runtime.
Решение. Делаем типом сам формат:
type Entity = "user" | "order" | "payment";
type Action = "created" | "updated" | "deleted";
type EventName = `${Entity}.${Action}`; // 9 допустимых комбинаций
function emit(event: EventName, payload: unknown) { /* ... */ }
emit("payment.created", payload); // OK
emit("payment.done", payload); // Ошибка: такого события нетДобавите новую сущность — все комбинации появятся автоматически, без ручного обновления списка.
Когда. Короткие строки с ограниченным форматом: имена событий, префиксы cache-ключей, названия прав (вроде "orders:read"). Но в меру: если сложные типы-парсеры строк становятся нечитаемыми — это тоже долг, только в другой форме.
Итог
У всех 6 паттернов одна логика: чем раньше поймана ошибка, тем она дешевле. any работает наоборот — сдвигает ошибку в самое дорогое место, в production. Поэтому я считаю any долгом: сегодня экономите 5 минут, потом возвращаете днём дебага.
Внедрять всё сразу не обязательно. Убедитесь, что включён strict: true, замените any на границах на unknown, поставьте zod на один самый болезненный API — разницу почувствуете уже в первую неделю. Не просто пишите на TypeScript — пользуйтесь им: компилятор должен работать на вас, а не против.