Типизируем CRM · От HTTP к модели клиента
По компании клик работает, по сделке нет. Функция загрузки строит /api/entity/D_42, но сделки живут на /api/deal/:id. После исправления адреса карточка всё ещё пуста: сервер вернул { result: deal }, а интерфейс читает response.title.
Вот две разные ошибки. Первую может поймать связь идентификаторов с моделью. Вторую обнаружит проверка формы ответа. Типы полезны на всём пути данных, но каждую границу нужно пройти явно. Общий файл типов не делает сеть доверенной.
Это финал практикума по записи занятия. Модель возьми из урока о CRM, ограничения произвольного изменения объекта из урока о патчах. Код восстановлен для урока, не скопирован из отсутствующего в расшифровке репозитория.
1. Транспорт не знает, что такое сделка
Транспортная функция умеет вызвать HTTP, проверить статус и прочитать JSON. Она не должна угадывать тип результата по желанию вызывающего кода:
export async function fetchJson(...args: Parameters<typeof fetch>): Promise<unknown> {
const response = await fetch(...args);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body: unknown = await response.json();
return body;
}
Parameters<typeof fetch> извлекает кортеж аргументов: первый параметр и необязательный RequestInit. Заголовки, метод и тело остаются совместимыми с fetch, а их объявления не нужно копировать. Подробнее об Parameters, ReturnType и Awaited в уроке о программировании на типах.
Это асинхронная функция с результатом Promise<unknown>, не Promise<Response> и не Promise<ReturnType<typeof fetch>>: fetch возвращает обещание Response, а мы уже прочитали его тело.
Не называй свой псевдоним URL, если рядом нужен глобальный класс URL. Если отдельные псевдонимы помогают, используй FetchInput = Parameters<typeof fetch>[0] и FetchInit = Parameters<typeof fetch>[1].
Опасная версия выглядит правдоподобно:
async function unsafeFetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}
response.json() в стандартных типах даёт any. Поэтому функция может «вернуть» T без доказательства. Запись unsafeFetchJson<Deal>(url) не ставит проверку в сеть, а просит компилятор поверить. После стирания типов сервер всё ещё может прислать { error: 'Нет доступа' }.
В учебном примере ошибки выбрасываются, чтобы сосредоточиться на границе данных. В браузерном коде BatSchool клиент возвращает neverthrow.ResultAsync и различает сетевую ошибку, HTTP, разбор ответа и отмену. Полный разбор этого договора есть в уроке о надёжности. Не переноси учебный throw в наш прикладной клиент как новую конвенцию.
2. Договор ответа живёт рядом с загрузчиком
У учебного API три формы:
| Адрес | Успешный ответ | Что получает интерфейс |
|---|---|---|
/api/search?q=... | массив SearchItem | короткие строки поиска |
/api/entity/:id | { result: Entity } | человек или компания |
/api/deal/:id | { result: WireDeal } | сделка после добавления kind |
Массив поиска не завёрнут в result. Два запроса подробностей завёрнуты. Это неудобный, но возможный договор. Нельзя извлекать result внутри универсального fetchJson: тогда список поиска сломается.
Ниже рабочие схемы для модели из урока 08. Сохрани их в public/api.ts, установи zod@4 в учебном проекте. shared/model.ts содержит объявления предыдущего урока; импорт только типов исчезнет из JavaScript.
import { z } from 'zod';
import type { Company, Deal, Entity, EntityId, Person, PrefixedId } from '../shared/model.js';
import type { DealId } from '../shared/model.js';
import type { SearchItem } from '../shared/api.js';
function idSchema<P extends string>(prefix: P) {
return z.custom<PrefixedId<P>>((value) =>
typeof value === 'string' && value.startsWith(`${prefix}_`) && value.length > prefix.length + 1,
);
}
const namedShape = {
name: z.string(),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
tags: z.array(z.string()),
custom: z.record(z.string(), z.unknown()),
};
const PersonSchema = z.object({
...namedShape,
id: idSchema('P'),
kind: z.literal('person'),
email: z.string().nullable(),
}) satisfies z.ZodType<Person>;
const CompanySchema = z.object({
...namedShape,
id: idSchema('C'),
kind: z.literal('company'),
domain: z.string().nullable(),
foundedAt: z.iso.datetime().nullable(),
}) satisfies z.ZodType<Company>;
const EntitySchema = z.discriminatedUnion('kind', [PersonSchema, CompanySchema]);
satisfies сверяет выход схемы с моделью, сохраняя конкретный тип z.object, который нужен z.discriminatedUnion. Аннотация : z.ZodType<Person> скрыла бы эту информацию.
Схема idSchema требует непустой суффикс. Это более сильный договор, чем один шаблонный тип. email здесь проверяется как строка, а не как почтовый адрес; префикс не подтверждает существование записи. z.iso.datetime() проверяет формат даты, но не правило «создание не позже обновления». Каждый такой договор добавляется осознанно.
Для нового проекта предпочтительнее выводить типы из схемы, чтобы не поддерживать описание полей дважды. Здесь отдельная модель уже существует после миграции, и satisfies z.ZodType<...> помогает сверить выход схемы с ней. Это проверка совместимости, не доказательство полного равенства двух описаний.
Продолжи файл схемами ответов и загрузчиками:
const WireDealSchema = z.object({
id: idSchema('D'),
title: z.string(),
amount: z.number().finite().nonnegative(),
ownerId: idSchema('P'),
contactIds: z.array(idSchema('P')),
stage: z.enum(['new', 'won', 'lost']),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
});
const EntityResponseSchema = z.object({ result: EntitySchema });
const DealResponseSchema = z.object({ result: WireDealSchema });
export async function fetchEntity(id: EntityId): Promise<Entity> {
const body = await fetchJson(`/api/entity/${encodeURIComponent(id)}`);
return EntityResponseSchema.parse(body).result;
}
export async function fetchDeal(id: DealId): Promise<Deal> {
const body = await fetchJson(`/api/deal/${encodeURIComponent(id)}`);
const wire = DealResponseSchema.parse(body).result;
return { ...wire, kind: 'deal' };
}
Именно fetchDeal, не транспорт, знает про поле result и отсутствующий в старом API тег. Это слой операции: HTTP превращается в проверенную модель, с которой умеет работать интерфейс.
Схемы используют Zod, поэтому для запуска браузерного файла с голым импортом zod уже нужен сборщик либо настроенная карта импортов. В уроке 07 мы сознательно жили без сборщика. Там транспилировали только собственный код. tsc не упакует Zod и не заставит браузер разрешать имя пакета. Пример загрузчиков можно проверить через tsx; для клиентского приложения подключи сборщик, как в разделе про инструменты фронтенда.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.