Capstone: выкатываем одну фичу, типизированную насквозь
Финал: выкатываем фичу «создать + показать постранично комментарии» насквозь типобезопасно. Одна схема zod порождает все слои; дискриминированный Result обрабатывается исчерпывающе; граница валидируется, а не приводится; тип клиента выводится — и каждый шаг называет свой юнит.
У вас одна фича на выкатку: создать комментарий, а затем показать комментарии треда постранично. Строка в Postgres, эндпойнт API, вызов с клиента, компонент <CommentList>. Каждому слою нужна форма комментария. Юниорский рефлекс — типизировать эту форму четыре раза (один раз для БД, один для ответа API, один для клиента, один для пропсов) и «синхронизировать» их руками. Сеньорский ход — объявить её один раз и дать компилятору протянуть её через все слои. Этот урок связывает воедино весь трек: мы типизируем именно эту фичу правильно и называем юнит, откуда взят каждый ход.
Один источник истины, затем вывод (юниты 00, 02, 05, 08)
Вся стратегия держится на одном правиле из юнита 08: выводи, не дублируй. Выбери один артефакт, определяющий комментарий, и сделай каждый другой тип функцией от него. Схема zod — сильнейший выбор, потому что она одновременно рантайм-валидатор и статический тип:
import { z } from "zod";
// ЭТО источник истины. Всё ниже по течению выводится из него.
export const Comment = z.object({
id: z.string().uuid(),
threadId: z.string().uuid(),
body: z.string().min(1).max(2000),
authorId: z.string().uuid(),
createdAt: z.coerce.date(),
});
// `z.infer` превращает рантайм-схему в статический тип (`infer` из юнита 05,
// тип-vs-значение из юнита 00: `Comment`-значение живёт в пространстве значений,
// `Comment`-тип — в пространстве типов; одно имя, два пространства имён).
export type Comment = z.infer<typeof Comment>;
// ^? { id: string; threadId: string; body: string; authorId: string; createdAt: Date }typeof Comment читает значение (объект схемы) и поднимает его в пространство типов; z.infer затем разворачивает его в объектный тип. Одна правка схемы — добавить editedAt — и каждый выведенный тип меняется вместе с ней. Нет второго объявления, которое можно забыть.
Вход для создания — строгое подмножество, и его мы тоже выводим, а не пишем руками (юнит 05 Pick/Omit, юнит 02 объектные типы):
export const NewComment = Comment.pick({ threadId: true, body: true, authorId: true });
export type NewComment = z.infer<typeof NewComment>;
// ^? { threadId: string; body: string; authorId: string }Моделируй результат дискриминированным объединением, обрабатывай исчерпывающе (юнит 02)
Когда ты возвращаешь Comment | null, вызывающая сторона знает, что вызов провалился, но не почему — и эта потерянная информация выливается в общие сообщения об ошибках и в оборонительные цепочки if, которые всё равно покрывают не все случаи. createComment может завершиться успехом, упереться в ошибку валидации или в несуществующий тред. Не возвращай Comment | null, теряя почему не вышло — смоделируй исходы дискриминированным объединением (discriminated union — объединение с общим дискриминирующим полем-тегом) по литеральному ключу tag (юнит 02), чтобы сужение по tag сообщало компилятору, какие именно остальные поля есть:
type CreateResult =
| { tag: "created"; comment: Comment }
| { tag: "invalid"; issues: string[] }
| { tag: "thread_missing"; threadId: string };
function describe(r: CreateResult): string {
switch (r.tag) {
case "created": return `#${r.comment.id}`; // r.comment в области видимости
case "invalid": return r.issues.join(", "); // r.issues в области видимости
case "thread_missing": return `no thread ${r.threadId}`;
default: {
// Страж исчерпываемости: если добавить новый tag и не обработать его,
// `r` здесь перестаёт быть `never`, и эта строка не скомпилируется.
const _exhaustive: never = r;
return _exhaustive;
}
}
}Присваивание в never — приём из юнита 02: в полностью обработанном switch анализ потока управления сужает r до never в default, и присваивание проходит проверку. Когда позже добавишь четвёртый вариант и забудешь case — r станет этим вариантом, несовместимым с never, и компилятор укажет на точную дыру, а не даст необработанному случаю молча провалиться насквозь.
Валидируй на границе — типы лишь обещания, пока не проверены (юнит 08)
Вот ход, отделяющий настоящую типобезопасность от театра. Драйвер БД отдаёт вам unknown (или хуже — any). Статическая аннотация на этом значении — обещание, которого рантайм не давал (юнит 08 и тезис о стирании типов из юнита 00). Вы делаете обещание истинным разбором (parse), а не приведением:
declare function dbQuery(sql: string, args: unknown[]): Promise<unknown[]>;
async function listComments(threadId: string): Promise<Comment[]> {
const rows = await dbQuery("select * from comments where thread_id = $1", [threadId]);
// ^? unknown[] — драйвер ничего не знает о нашей форме
// НЕПРАВИЛЬНО (так не делайте — это анти-паттерн из юнита 08):
// return rows as Comment[]; // ложь: ноль проверок в рантайме, createdAt всё ещё строка
// ПРАВИЛЬНО: разбор на границе. z.array(Comment) приводит createdAt и отбрасывает мусор.
return z.array(Comment).parse(rows);
// ^? Comment[] — теперь тип *заработан*, а не заявлен
}as Comment[] скомпилируется, а затем взорвётся в рантайме при первом же createdAt, который оказался строкой, а не Date. parse превращает кривую строку в брошенный ZodError на границе, где можно залогировать и вернуть 400 — вместо загадочного .getTime is not a function на три слоя глубже.
▸Почему это работает
Почему parse строго лучше, чем написанный руками страж типа? Рукописная function isComment(x: unknown): x is Comment кодирует форму третий раз и может разойтись со схемой — а TypeScript не проверяет, что тело предиката действительно соответствует заявленному типу, так что багнутый страж — это молчаливый as. z.array(Comment).parse выводит проверку из того же источника истины, поэтому рантайм-проверка и статический тип не могут разойтись. Рукописные стражи приберегите для форм, которые нельзя выразить схемой.
Один маленький дженерик, в меру умный (юниты 03, 04, 05)
Эндпойнт списка постраничный, и каждый постраничный эндпойнт делит общую форму. Захвати её один раз дженериком — но держи скучной. Это урок сдержанности из юнита 05: дженерик зарабатывает место, убирая дублирование, а не красуясь гимнастикой условных типов, которая взрывает время компиляции.
// Одна переиспользуемая форма. `T` выводится в месте вызова, но тело остаётся
// простым объектом (юнит 03 дженерик-функции, юнит 04 система типов,
// юнит 05 «остановись, пока не стало умно»).
interface Paginated<T> {
items: T[];
nextCursor: string | null;
total: number;
}
function makePage<T>(items: T[], total: number, nextCursor: string | null): Paginated<T> {
return { items, total, nextCursor };
}
const page = makePage(await listComments("t-1"), 42, "cursor-43");
// ^? Paginated<Comment>
page.items[0].body; // полностью типизировано; T выведен как Comment из аргументаT выводится из аргумента items — без аннотации в месте вызова. Мы намеренно не потянулись за DeepPartial<T> или условным кодировщиком курсора: по юниту 05 эта умность стоит понятности для читателя и времени компиляции (глубоко рекурсивные условные типы — реальный обрыв производительности) и не даёт ничего, что команде нужно здесь. В меру умно = минимум, убивающий дублирование.
satisfies для таблицы маршрутов; страж типа на границе авторизации (юнит 06)
Таблица маршрутов — это конфиг: вы хотите сохранить литеральные типы каждого обработчика (чтобы routes.listComments держал точную сигнатуру), при этом проверив весь объект на соответствие контракту. Ровно для этого и нужен satisfies (юнит 06, TS 4.9+) — : RouteTable расширил бы значения; satisfies RouteTable проверяет без расширения:
type Handler = (input: unknown) => Promise<unknown>;
type RouteTable = Record<string, Handler>;
const routes = {
createComment: async (input: unknown) => z.array(Comment).parse([]),
listComments: async (input: unknown) => listComments(NewComment.parse(input).threadId),
} satisfies RouteTable;
// Ключи остаются литеральными — автодополнение знает "createComment" | "listComments",
// а не просто `string`, ведь `satisfies` проверил форму без расширения.
type RouteName = keyof typeof routes;
// ^? "createComment" | "listComments"На границе авторизации пользовательский страж типа (юнит 06) сужает неаутентифицированный запрос до аутентифицированного на весь остаток обработчика:
type Ctx = { userId: string | null };
function isAuthed(ctx: Ctx): ctx is Ctx & { userId: string } {
return ctx.userId !== null;
}
// после `if (isAuthed(ctx))` ctx.userId — это `string`, а не `string | null`.Поток между клиентом и сервером: выведи клиент, не переобъявляй его (юнит 08)
Вознаграждение. С tRPC-подобным серверным роутером (tRPC — библиотека для типобезопасного RPC между клиентом и сервером без дополнительной схемы) тип клиента выводится из сервера, так что форма комментария, объявленная один раз в схеме, доходит до React-компонента без единого переобъявления (юнит 08 trpc-end-to-end). Перетипизация ответа на клиенте — то самое дублирование, которое мы весь урок убивали:
// server.ts — форма роутера это просто объект процедур
const appRouter = {
comments: {
list: (input: { threadId: string }): Promise<Comment[]> => listComments(input.threadId),
create: (input: NewComment): Promise<CreateResult> => { throw new Error("impl elided"); },
},
};
export type AppRouter = typeof appRouter; // контракт, выведенный, а не написанный
// client.tsx — клиент типизируется ИЗ AppRouter; здесь форма не переобъявляется
declare const trpc: {
comments: { list: { useQuery: (i: { threadId: string }) => { data?: Comment[] } } };
};
function CommentList({ threadId }: { threadId: string }) {
const { data } = trpc.comments.list.useQuery({ threadId });
// ^? Comment[] | undefined — выведено насквозь из единственной схемы
return data?.map((c) => c.body) ?? []; // c это Comment, полностью типизирован
}Поменяйте body на text в схеме zod — и React-компонент перестанет компилироваться: контракт по проводу обеспечивается выводом, а не дисциплиной. Про окружающую систему смотрите React Server Components и потоковую передачу Suspense (frontend) — где эти данные загружаются и рендерятся, и Моделирование REST-ресурсов (apis) — про форму эндпойнта, если вы отдаёте REST вместо tRPC.
Чего НЕ делать — переформулированные ловушки юнита 08
- Никакого
asна границе.rows as Comment[]— непроверенная ложь;z.array(Comment).parse(rows)зарабатывает тип. Единственное, чтоasпокупает на границе, — это рантайм-сбой позже, дальше от причины. - Выводи, не дублируй. Если форма комментария появляется более чем в одном рукописном месте, удаление поля становится ручной охотой. Схема →
z.infer→Pick/Omit→typeof appRouterозначает: одна правка расходится по всему. - Следи за производительностью вывода. Умный дженерик, который вы не написали, стоит ноль времени компиляции. Глубоко рекурсивные условные/маппированные типы (юнит 05) могут застопорить
tscи подсказки редактора — тянитесь к ним, только когда простой интерфейс действительно не может выразить форму.
В listComments драйвер возвращает unknown[]. Вам нужно значение типа Comment[]. Какая строка даёт настоящую, обеспеченную рантаймом типобезопасность?
Расставьте ходы по сборке фичи комментариев с типобезопасностью насквозь — от источника истины наружу:
- 1 Объяви ОДИН источник истины: схему zod Comment (и NewComment = Comment.pick(...))
- 2 Выведи статические типы: type Comment = z.infer<typeof Comment> — без второго объявления
- 3 Смоделируй исходы дискриминированным объединением CreateResult и обработай исчерпывающе (never в default)
- 4 Валидируй на границе рантайма: z.array(Comment).parse(rows), никогда не `as Comment[]`
- 5 Зафиксируй таблицу маршрутов через `satisfies RouteTable`, чтобы сохранить литеральные типы обработчиков
- 6 Экспортируй typeof appRouter, чтобы клиент вывел Comment[] без переобъявления
Схема zod — единственный источник истины, и z.infer / typeof / Pick / typeof appRouter все читают из неё. Одним словом: каждый другой тип фичи лучше описать не как копию, а как ____ этой схемы.
- 01Что конкретно означает «один источник истины, затем вывод» для фичи комментариев, и какие прежние механизмы выполняют вывод?
- 02Почему `z.array(Comment).parse(rows)` — правильный ход на границе, а `rows as Comment[]` — неправильный?
- 03Как форма комментария доходит до клиента без переобъявления, и что такое «в меру умно» для дженерика пагинации?
Это и есть весь трек в одной фиче: объяви один раз, выведи всё (юниты 00, 02, 05), смоделируй исходы дискриминированными объединениями и обработай исчерпывающе (юнит 02), разбирай на границе, чтобы типы были заработаны, а не обещаны (юнит 08), напиши один маленький дженерик и не больше (юниты 03, 04, 05), зафиксируй конфиг через satisfies и защити границы предикатами типов (юнит 06) и дай выводу донести контракт до клиента (юнит 08). Если какой-то из этих ходов казался шатким — глубокие юниты там, куда стоит вернуться: условные типы и служебные типы с нуля (юниты 04, 05) ради машинерии infer/Pick, дженерик-функции и ограничения (юнит 03) ради Paginated<T>, перегрузки и this (юнит 06) ради satisfies и стражей, и zod / tRPC / частые ловушки (юнит 08) ради границы и провода. Теперь, когда встретишь тип, объявленный руками во втором месте — отдельный ApiComment, переписанный ответ, приведение на границе драйвера — ты точно знаешь, какой ход пропущен и к какому юниту обратиться, чтобы закрыть дыру.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.