Типы и тесты как документация
Типы и тесты — проверяемая машиной документация, которая не может протухнуть. Точный тип кодирует инвариант, который комментарий лишь заявляет: размеченное объединение запрещает недопустимые состояния, а названный тест фиксирует поведение как спецификацию.
// amount must be > 0 висит над функцией. Полгода спустя кто-то переписывает её тело, отрицательная сумма проскакивает, а комментарий по-прежнему на месте — спокойный, уверенный и неверный. Никто не соврал; у комментария просто нет силы помешать коду уехать от него. Комментарий — это заявление, которое компилятор никогда не читает, а тест-раннер никогда не проверяет.
Теперь представь тот же инвариант, записанный как PositiveInt — тип, который можно сконструировать только через проверку на границе. Отрицательная сумма никогда не доходит до функции, потому что система типов отказывается построить такое значение. «Документация» превратилась в стену. Этот урок — про два вида документации, которые не могут протухнуть, потому что машина их обеспечивает: типы и тесты.
После этого урока ты можешь объяснить, почему точный тип документирует инвариант, который комментарий способен лишь заявить; использовать размеченные объединения, чтобы запретить недопустимые состояния, и брендированные типы, чтобы остановить путаницу единиц и идентификаторов; превратить комментарий amount must be > 0 в PositiveInt, разобранный на границе; назвать тест тем поведением, которое он гарантирует, чтобы набор тестов читался как спецификация; и распознать режим отказа — номинальные типы для тривиальных значений и тесты, фиксирующие реализацию вместо поведения.
Комментарий заявляет инвариант; тип его кодирует. Документация протухает, потому что нет механизма, поддерживающего её истинной. Комментарий не проверяет ровно никто — ни компилятор, ни CI, ни следующий читатель, скользнувший по нему взглядом. В тот момент, когда код меняется, а комментарий нет, у тебя рядом с работающим кодом сидит уверенная ложь. Тип — иной по природе: он часть программы. Если код противоречит типу, сборка падает. Поэтому вопрос для любого инварианта, который стоит сформулировать, не «стоит ли это закомментировать?», а «могу ли я сделать документацией сам компилятор?»
// rots silently:
// precondition: status is one of 'draft' | 'sent' | 'paid'
function advance(invoice: { status: string }) { /* ... */ }
// cannot rot — the compiler is the doc:
type Status = "draft" | "sent" | "paid";
function advance(invoice: { status: Status }) { /* ... */ }Строковая версия говорит про три состояния; объединение обеспечивает три состояния. Четвёртое значение — ошибка компиляции, а не рантайм-сюрприз через три деплоя.
Размеченные объединения запрещают недопустимые состояния. Самые дорогие баги — это не неверные значения, а невозможные комбинации, которые тип разрешил: запрос, который одновременно loading: true и имеет data; заказ, который cancelled, но всё ещё имеет shippedAt. Комментарий («не задавай data во время загрузки») — это молитва. Размеченное объединение удаляет недопустимое состояние из типа, так что ни один путь кода не может его сконструировать.
// allows the impossible: loading && data && error all set at once
type Bad = { loading: boolean; data?: User; error?: Error };
// the four real states, and only those:
type Fetch =
| { tag: "idle" }
| { tag: "loading" }
| { tag: "ok"; data: User }
| { tag: "err"; error: Error };Теперь data существует только когда tag === "ok". Читатель узнаёт весь конечный автомат из типа, а компилятор отвергает любой обработчик, забывший случай. Тип — это и документация, и проверка на полноту.
Брендированные (непрозрачные) типы документируют единицы и идентичность, останавливая молчаливую путаницу. TypeScript структурен: UserId и OrderId оба — string, поэтому компилятор с радостью позволит передать один туда, где место другому. Классический прод-инцидент ровно про это — функция принимает (userId: string, accountId: string), и кто-то меняет аргументы местами. Бренд придаёт примитиву номинальную идентичность без рантайм-стоимости.
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type Cents = Brand<number, "Cents">;
function chargeAccount(user: UserId, amount: Cents) { /* ... */ }
// chargeAccount(orderId, dollars) // ✗ compile error — wrong brandБренд — это документация того, что amount в центах, а не в долларах, и что этот string — id пользователя, а не любая строка. Но заметь цену: это окупается только там, где путаница правдоподобна и дорога. Брендировать каждый string — режим отказа из шага 5.
Хорошо названный тест — это исполняемая спецификация; набор тестов — живая документация. Комментарий, описывающий поведение, дрейфует; тест, описывающий поведение, падает, когда поведение дрейфует. Фокус в именовании: тест с именем test('works') ничего не документирует, а test('rejects an amount of zero') — это предложение в спецификации, которое к тому же выполняется. Прочитанные сверху вниз, хорошо названные тесты — самое точное прозаическое описание модуля из существующих, потому что каждая строка проверяется на каждом коммите.
describe("parsePositiveInt", () => {
it("returns a PositiveInt for a positive integer", () => {
expect(parsePositiveInt(5)).toEqual({ ok: true, value: 5 });
});
it("rejects zero", () => {
expect(parsePositiveInt(0).ok).toBe(false);
});
it("rejects negatives and non-integers", () => {
expect(parsePositiveInt(-1).ok).toBe(false);
expect(parsePositiveInt(2.5).ok).toBe(false);
});
});Ты можешь прочитать эти три имени и узнать контракт, не читая тело. Это документация, которая ловит дрейф, — комментарий, который умеет выполняться.
Замени комментарий типом, разобранным на границе. Отправная точка — классический протухающий контракт:
// amount must be a positive integer — callers, please check!
function applyCredit(accountId: string, amount: number) {
// ...nothing here actually enforces the comment
balance += amount;
}Комментарий перекладывает инвариант на каждого вызывающего, и ничего не падает, когда один из них забыл. Мы переносим проверку на границу и возвращаем тип, доказывающий, что она пройдена, — «разбирай, а не проверяй»: вместо утверждения, что значение хорошее, произведи значение, чей тип означает, что оно хорошее.
type PositiveInt = Brand<number, "PositiveInt">;
function parsePositiveInt(n: number): Result<PositiveInt> {
if (!Number.isInteger(n) || n <= 0) {
return { ok: false, error: "amount must be a positive integer" };
}
return { ok: true, value: n as PositiveInt };
}
// the function no longer documents the rule in a comment —
// it requires the proof in its signature:
function applyCredit(accountId: AccountId, amount: PositiveInt) {
balance += amount; // unconditionally safe; the type is the guarantee
}Теперь инвариант обеспечивается ровно в одном месте (в парсере), сигнатура и есть документация, а единственный способ получить PositiveInt — пройти проверку. Дополни это тестом, названным по поведению:
it("applyCredit cannot be called with a non-positive amount", () => {
const parsed = parsePositiveInt(0);
expect(parsed.ok).toBe(false); // it never produces the value to pass in
});Комментарий исчез, и ничто из заявленного им не может молча стать ложным: компилятор обеспечивает форму, парсер обеспечивает значение, а имя теста фиксирует намерение. Три неподверженных протуханию документа заменили одно полное надежды предложение.
▸Почему это работает
Почему тип — это «документация», а не просто ограничение? Потому что задача документации — донести намерение до следующего читателя, и точный тип делает ровно это — parsePositiveInt(n): Result<PositiveInt> сообщает читателю правило (только положительные целые), режим отказа (может упасть, вот форма) и гарантию ниже по течению (всё, что типизировано как PositiveInt, уже прошло проверку). Комментарий доносит то же намерение, но без обеспечения, поэтому деградирует в шум в тот момент, когда становится неверным. Тип доносит намерение и держит себя честным. Senior-ход — протолкнуть инвариант вниз, в систему типов, чтобы самый читаемый артефакт кодовой базы — сигнатуры — нёс истину, а компилятор был корректором, который никогда не устаёт.
▸Частая ошибка
Два симметричных режима отказа. Переусложнение: брендировать каждый тривиальный примитив (Brand<string, "FirstName">) или моделировать булево как объединение из четырёх случаев. Номинальные типы окупаются только там, где путаница правдоподобна, а цена ошибки реальна — id, деньги, единицы измерения, токены безопасности. Везде ещё они добавляют церемонию и стоимость чтения без всякой защиты, что само по себе разновидность протухания. Тесты, фиксирующие реализацию, а не поведение: expect(spy).toHaveBeenCalledWith(...) или утверждение про приватное поле документирует, как код работает сегодня, поэтому любой рефакторинг краснит набор тестов, даже когда поведение не изменилось. Это приучает команду рефлекторно игнорировать или переписывать тесты — противоположность надёжной спецификации. Называй и утверждай наблюдаемое поведение («rejects zero»), а не механизм, чтобы тест пережил каждый рефакторинг, сохраняющий контракт.
Функция несёт комментарий `// amount must be a positive integer`. Какое изменение лучше всего превращает этот протухающий комментарий в документацию, которая не может уехать в неактуальность?
Комментарии протухают, потому что ни один инструмент не держит их истинными; типы и тесты — это документация, которую обеспечивает машина, поэтому они не могут молча уехать в неактуальность. Точный тип кодирует инвариант, который комментарий способен лишь заявить: размеченное объединение запрещает недопустимые состояния, брендированный/непрозрачный тип останавливает путаницу единиц и идентификаторов, а readonly документирует неизменяемость. Senior-паттерн — разбирай, а не проверяй: проверь на границе и верни значение, чей тип означает, что проверка пройдена, чтобы сигнатура стала документацией, а компилятор — корректором. Тест, названный по поведению, — это исполняемая спецификация, которая фиксирует намерение и ловит дрейф на каждом коммите. Режим отказа симметричен: номинальные типы для тривиальных значений добавляют церемонию без защиты, а тесты, утверждающие реализацию вместо поведения, краснеют на безобидных рефакторингах. Тянись к системе типов там, где инвариант реален, а ошибка дорога; называй тесты по поведению, никогда — по механизму.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.