open atlas
↑ К треку
Система типов TypeScript вглубь TS · 09 · 01

Capstone: выкатываем одну фичу, типизированную насквозь

Финал: выкатываем фичу «создать + показать постранично комментарии» насквозь типобезопасно. Одна схема zod порождает все слои; дискриминированный Result обрабатывается исчерпывающе; граница валидируется, а не приводится; тип клиента выводится — и каждый шаг называет свой юнит.

TS Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

У вас одна фича на выкатку: создать комментарий, а затем показать комментарии треда постранично. Строка в 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, и присваивание проходит проверку. Когда позже добавишь четвёртый вариант и забудешь caser станет этим вариантом, несовместимым с 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.inferPick/Omittypeof appRouter означает: одна правка расходится по всему.
  • Следи за производительностью вывода. Умный дженерик, который вы не написали, стоит ноль времени компиляции. Глубоко рекурсивные условные/маппированные типы (юнит 05) могут застопорить tsc и подсказки редактора — тянитесь к ним, только когда простой интерфейс действительно не может выразить форму.
Викторина

В listComments драйвер возвращает unknown[]. Вам нужно значение типа Comment[]. Какая строка даёт настоящую, обеспеченную рантаймом типобезопасность?

Расставь шаги по порядку

Расставьте ходы по сборке фичи комментариев с типобезопасностью насквозь — от источника истины наружу:

  1. 1 Объяви ОДИН источник истины: схему zod Comment (и NewComment = Comment.pick(...))
  2. 2 Выведи статические типы: type Comment = z.infer<typeof Comment> — без второго объявления
  3. 3 Смоделируй исходы дискриминированным объединением CreateResult и обработай исчерпывающе (never в default)
  4. 4 Валидируй на границе рантайма: z.array(Comment).parse(rows), никогда не `as Comment[]`
  5. 5 Зафиксируй таблицу маршрутов через `satisfies RouteTable`, чтобы сохранить литеральные типы обработчиков
  6. 6 Экспортируй typeof appRouter, чтобы клиент вывел Comment[] без переобъявления
Закончи аналогию

Схема zod — единственный источник истины, и z.infer / typeof / Pick / typeof appRouter все читают из неё. Одним словом: каждый другой тип фичи лучше описать не как копию, а как ____ этой схемы.

Вспомните перед уходом
  1. 01
    Что конкретно означает «один источник истины, затем вывод» для фичи комментариев, и какие прежние механизмы выполняют вывод?
  2. 02
    Почему `z.array(Comment).parse(rows)` — правильный ход на границе, а `rows as Comment[]` — неправильный?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.