Надёжность и типы на границах
Три предыдущих урока строили язык. Сначала синтаксис, потом конструкции программирования на типах, потом трюки. Этот урок переключается на вопрос, ради которого вся система типов вообще существует. Как сделать так, чтобы программа не падала на проде по причинам, которые компилятор мог поймать.
Базовая идея проста и неудобна одновременно. Типы это не аннотации, типы это инструмент проверки на границе. Доменная модель внутри системы строго типизирована. На границе сети, базы, файлов, очереди ты парсишь, а не валидируешь. Что внутри границы (сервер, доменный слой) живёт по типам; что снаружи (HTTP-запрос, чужой JSON, переменные окружения, строка из БД) живёт как unknown до явного парсинга.
В этом уроке шесть тем. Где брать типы. Границы доверия. Рантайм-схемы (zod, valibot, Effect Schema). Branded-типы. Трагедия Promise и переход к Result. Railway-Oriented Programming. Плюс короткий обзор “безопасного API” (свои сообщения об ошибках, шаблон-строитель). Урок 05 продолжит эту линию через Effect-TS.
1. Где брать типы
Четыре источника, по убыванию ценности.
1) Кодогенерация из контракта. OpenAPI или Swagger в TS, GraphQL в TS, gRPC и protobuf в TS, sqlc-стиль. Один источник истины (контракт), типы и валидаторы рантайма генерятся автоматически.
# OpenAPI в TS
$ pnpm dlx openapi-typescript api/openapi.json -o src/api/schema.d.ts
# GraphQL Codegen
$ pnpm dlx graphql-codegen --config codegen.yml
Это самый надёжный способ. Фронт и бэк не разъезжаются, потому что обе стороны читают один файл.
2) Рантайм-схемы как источник. Пишешь схему один раз (zod, valibot, Effect Schema), из неё компилятор выводит TypeScript-тип. Та же схема валидирует во время выполнения.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
// эквивалент: { id: string; name: string; email: string }
Один источник истины. Тип и проверка во время выполнения живут вместе.
3) Вывод из as const. Когда контракт сидит у тебя в коде (статический список, конфиг), as const плюс индексный доступ даёт статические типы без дублирования.
const ROUTES = ['/home', '/login', '/users/:id'] as const;
type Route = typeof ROUTES[number];
// '/home' | '/login' | '/users/:id'
typeof это оператор “возьми тип значения”.
4) Ручной type Foo = {...}. Самое последнее средство. Нужен там, где ничего из выше не подходит: внутренние доменные типы, у которых нет контракта снаружи.
Правило: никогда не дублируй. Если у тебя есть схема, тип выводи через z.infer. Если у тебя есть OpenAPI, не пиши руками. Любое дублирование рано или поздно даёт расхождение.
2. Границы доверия
Программа делится на два мира. Внутренний (доверенный) и внешний (не доверенный). Между ними граница.
Внешний мир: HTTP-запросы, БД, файлы, очередь, переменные окружения, localStorage, ответы чужих сервисов. Тут всё unknown. Положить такое в доменный тип без проверки нельзя.
[Внешний мир] [Граница] [Внутренняя модель]
unknown / any ----parse / decode----> WeatherRecord, User, Order
Граница это место в коде. Контроллер, обработчик запроса, слой доступа к данным, потребитель очереди. На границе ты:
- принимаешь
unknown(всё, что пришло снаружи); - парсишь через схему (
zod/valibot/ …); - возвращаешь типизированный объект или ошибку.
После этого внутри границы можно работать со строгими типами без лишних проверок. Если ты внутри найдёшь себя в ситуации “а вдруг здесь не строка”, значит граница протекает: иди и почини её.
Это и есть “парси, не валидируй” Алексис Кинг (см. ресурсы). Валидация это проверка с возвратом boolean, после которой ты всё равно держишь unknown. Парсинг это преобразование, после которого ты держишь правильный тип или явную ошибку.
// плохо
function isWeather(x: unknown): boolean { /* ... */ }
if (isWeather(raw)) {
// raw всё равно unknown, сужения не случилось
// приходится верить себе на слово
}
// хорошо
function parseWeather(x: unknown): Result<WeatherRecord, ParseError> {
/* ... */
}
const r = parseWeather(raw);
if (r.isOk()) {
r.value; // WeatherRecord, гарантированно
}
В уроке 02-cs/06-fp-types-and-errors эта тема разворачивается на ФП-голос. Здесь мы делаем её рабочим инструментом.
3. Рантайм-схемы: zod, valibot, Effect Schema
Три зрелых выбора для проверки во время выполнения. У них разные акценты, выбирай по контексту.
zod. Самый популярный. Цепочечный API на объектах, отличные сообщения об ошибках, большая экосистема (zod-to-openapi, zod-prisma и другие), интегрирован в Hono и tRPC.
import { z } from 'zod';
const WeatherSchema = z.object({
city: z.string(),
temp: z.number(),
ts: z.number().int().nonnegative(),
});
type WeatherRecord = z.infer<typeof WeatherSchema>;
const r = WeatherSchema.safeParse(raw);
if (r.success) r.data; // WeatherRecord
else r.error.issues; // массив проблем
Размер сборки: около 57 КБ после минификации. Скорость: средняя.
valibot. Молодой, маленький, модульный (импортируешь только то, что используешь). Сборка в 10 раз меньше: около 7 КБ. Хорошо тришейкается, API на функциях.
import * as v from 'valibot';
const WeatherSchema = v.object({
city: v.string(),
temp: v.number(),
ts: v.pipe(v.number(), v.integer(), v.minValue(0)),
});
type WeatherRecord = v.InferOutput<typeof WeatherSchema>;
const r = v.safeParse(WeatherSchema, raw);
if (r.success) r.output;
else r.issues;
Полезно во фронте, где каждый килобайт заметен.
Effect Schema (модуль Schema внутри пакета effect, отдельного @effect/schema больше нет). Тесно интегрирован с Effect-TS, поддерживает двусторонние преобразования (decode/encode), branded-типы из коробки, есть кодеры в JSON-Schema и OpenAPI.
import { Schema } from 'effect';
const WeatherSchema = Schema.Struct({
city: Schema.String,
temp: Schema.Number,
ts: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)),
});
type WeatherRecord = Schema.Schema.Type<typeof WeatherSchema>;
// в Effect:
const program = Schema.decodeUnknownEffect(WeatherSchema)(raw);
Если в проекте уже Effect-TS, бери Effect Schema. Если нет, берём zod как универсальный вариант.
Сравнение, если выбираем впервые:
| Параметр | zod | valibot | Effect Schema |
|---|---|---|---|
| Размер сборки | около 57 КБ | около 7 КБ | средний |
| API | цепочечный | на функциях | схема как первый класс |
| Сообщения | отличные | хорошие | структурные |
| Преобразования | есть | есть | лучшие в классе |
| Branded | через трюк | через трюк | первоклассно |
| Экосистема | большая | растёт | связана с Effect |
Все три прошли проверку временем в проде, выбирай по контексту команды.
4. Branded-типы
Структурная типизация TypeScript даёт удобство, но мешает там, где нужны “теги” уровня типа. Классический баг: функция transfer(from: string, to: string, amount: number). Все три параметра взаимозаменяемы. На собеседовании и в проде путали from и to.
Branded-типы решают это, добавляя фантомное поле.
type Brand<B extends string> = { readonly __brand: B };
type UserId = string & Brand<'UserId'>;
type OrderId = string & Brand<'OrderId'>;
declare function getUser(id: UserId): User;
declare function getOrder(id: OrderId): Order;
const raw = 'u-1';
// getUser(raw); // ошибка: string не UserId
// getOrder(raw); // ошибка: string не OrderId
Brand во время выполнения не существует. Только на этапе компиляции. После сборки остаётся обычная string. Нулевая стоимость и именная типизация в структурной системе одновременно.
Умный конструктор превращает сырое значение в branded через проверку.
import { ok, err, type Result } from 'neverthrow';
class BrandError extends Error {
readonly _tag = 'BrandError' as const;
constructor(message: string) { super(message); }
}
const userIdRe = /^u-[\w-]+$/;
function mkUserId(raw: string): Result<UserId, BrandError> {
return userIdRe.test(raw)
? ok(raw as UserId)
: err(new BrandError(`bad UserId: ${raw}`));
}
Раньше as UserId это было явное “я знаю, что делаю”. В умном конструкторе это не лазейка, а единственный способ получить UserId, и он закрыт в одной функции с одной проверкой во время выполнения.
Альтернативные библиотеки: gcanti/newtype-ts (в стиле newtype), Effect Schema Branded (см. выше).
Поиграй с виджетом. Пять сценариев показывают, что компилятор ловит.
type Brand<B extends string> = { readonly __brand: B };
type UserId = string & Brand<'UserId'>;
type OrderId = string & Brand<'OrderId'>;
class BrandError extends Error { readonly _tag = 'BrandError' as const; }
function mkUserId(raw: string): Result<UserId, BrandError> {
return /^u-[\w-]+$/.test(raw)
? ok(raw as UserId)
: err(new BrandError(`bad UserId: ${raw}`));
}const raw: string = "u-1";
getUser(raw);(id: UserId) => User?string не assignable к UserId, потому что у UserId есть фантомный brand. Нужен smart constructor.
Brand стирается при компиляции, в JS-выводе остаётся обычная строка. Это zero-cost runtime ограничение: stronger types без overhead.
Применение в реальном коде.
type EmailAddress = string & Brand<'EmailAddress'>;
type Password = string & Brand<'Password'>;
type Hash = string & Brand<'Hash'>;
declare function login(email: EmailAddress, password: Password): Promise<Token>;
declare function hash(s: string): Promise<Hash>;
declare function verify(input: Password, stored: Hash): Promise<boolean>;
В этой сигнатуре невозможно случайно подсунуть хеш вместо пароля или email вместо пароля. Компилятор ловит на корню.
В BatSchool branded-типы сейчас не используются явно, но клиентский HTTP-слой и серверный auth это естественные места: токены сессий, хешированные пароли, токены magic-link, всё это типы, которые не должны путаться.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.