Статья о том, как единый типизированный контракт позволяет получить проверяемые на этапе компиляции сервер, клиент и React-хуки — без кодогенерации и без дублирования типов.
Дисклеймер: все описываемые библиотеки носят экспериментальный характер — они созданы в рамках эксперимента. Несмотря на это, покрытие тестами у них достаточно хорошее.
Предыстория
В предыдущей статье я рассказывал о @cleverbrush/schema — библиотеке валидации схем с fluent-API и runtime-интроспекцией. Схемы — это краеугольный камень всего фреймворка. Сегодня речь пойдёт о том, что на них строится: типизированный HTTP-сервер @cleverbrush/server и клиент @cleverbrush/client.
Классическая проблема выглядит так: у вас есть бэкенд на TypeScript и фронтенд на TypeScript, но типы между ними не разделены. Либо вы дублируете их в двух местах, либо запускаете кодогенерацию по OpenAPI-манифесту и работаете с «размороженными» типами, которые устаревают сразу после того, как меняется контракт.
Здесь работает другой подход: единый контракт API в виде TypeScript-модуля, который импортируется и сервером, и клиентом напрямую.
Контракт как единственный источник правды
Контракт — это объект, описывающий все эндпоинты приложения. Он создаётся в отдельном пакете (или файле), не содержит серверного кода и может безопасно импортироваться в браузер.
import { array, number, object, string } from '@cleverbrush/schema';
import { defineApi, endpoint, route } from '@cleverbrush/server/contract';
const TodoSchema = object({
id: number(),
title: string(),
completed: boolean()
});
const CreateTodoBodySchema = object({
title: string().minLength(1).required()
});
const ById = route({ id: number().coerce() })`/${t => t.id}`;
export const api = defineApi({
todos: {
list: endpoint
.get('/api/todos')
.query(object({ page: number().coerce().optional() }))
.responses({ 200: array(TodoSchema) }),
get: endpoint
.resource('/api/todos')
.get(ById)
.responses({ 200: TodoSchema, 404: null }),
create: endpoint
.post('/api/todos')
.body(CreateTodoBodySchema)
.responses({ 201: TodoSchema }),
delete: endpoint
.resource('/api/todos')
.delete(ById)
.responses({ 204: null })
}
});
Заметьте функцию route. Это tagged template, который задаёт путь с типизированными параметрами. В примере /${t => t.id} — TypeScript знает, что id имеет тип number (с coerce, то есть будет распарсен из строки URL). Если переименовать поле или написать t.userId там, где ключа нет, — получите ошибку компиляции.
Сервер
Контракт сам по себе не содержит серверной логики. На бэкенде его расширяют: добавляют авторизацию, инъекцию зависимостей и метаданные для OpenAPI.
Стоит отметить, что у @cleverbrush/server только одна внешняя зависимость — ws, и та нужна исключительно для WebSocket-подписок. Всё остальное — встроенный Node.js HTTP-сервер и собственные реализации роутинга, DI, валидации и батчинга.
import { createServer, mapHandlers } from '@cleverbrush/server';
import { api } from './contract.js';
import { DbToken } from './di/tokens.js';
// Расширяем контракт серверными деталями
const endpoints = {
todos: {
list: api.todos.list
.authorize(PrincipalSchema)
.inject({ db: DbToken }),
get: api.todos.get
.authorize(PrincipalSchema)
.inject({ db: DbToken }),
create: api.todos.create
.authorize(PrincipalSchema)
.inject({ db: DbToken }),
delete: api.todos.delete
.authorize(PrincipalSchema)
.inject({ db: DbToken })
}
};
Здесь .authorize(PrincipalSchema) указывает, что к этому эндпоинту нужна аутентификация. PrincipalSchema — это схема объекта principal (декодированный JWT), который будет доступен в хендлере. .inject({ db: DbToken }) — это инъекция зависимостей: хендлер получит db из DI-контейнера о котором речь пойдёт ниже.
DI-контейнер: токены и регистрация сервисов
В @cleverbrush/server зависимости разрешаются через @cleverbrush/di. Токен — это любая схема из @cleverbrush/schema, которой через .hasType() можно сопоставить конкретный тип. Т.к. схемы иммутабельные, то их можно безопасно использовать как ключи в DI-контейнере. Сам токен ничего не делает в рантайме; он служит ключом для DI-контейнера, а .hasType() просто сопоставляет с ней конкретный тип. Это сделано для того чтобы было возможно использовать внешние типы, которые невозможно создать через схемы, например Knex или объекты из любых других сторонних библиотек.
import { any } from '@cleverbrush/schema';
import type { Knex } from 'knex';
import type { DbContext } from '@cleverbrush/orm';
// Токен — экземпляр схемы с фантомным типом T
export const KnexToken = any().hasType<Knex>();
export const DbToken = any().hasType<DbContext<AppEntityMap>>();
export const ConfigToken = any().hasType<AppConfig>();
Токены регистрируются в контейнере через ServiceCollection. Возможные стратегии: addSingleton — один инстанс на всё приложение, addScoped — один инстанс на HTTP-запрос, addTransient — новый инстанс при каждом резолвинге.
import { ServiceCollection } from '@cleverbrush/di';
import knex from 'knex';
export function configureDI(services: ServiceCollection, config: AppConfig): void {
services.addSingleton(ConfigToken, config);
services.addSingleton(KnexToken, () =>
knex({ client: 'pg', connection: config.db.connectionString })
);
services.addSingleton(DbToken, (provider) => {
const knexInstance = provider.get(KnexToken);
return createDb(knexInstance, entityMap);
});
}
Ключевой момент здесь — как тип db попадает в хендлер. Когда вы пишете .inject({ db: DbToken }), эндпоинт запоминает в своей TypeScript-сигнатуре, что ключу db соответствует тип из DbToken (то есть DbContext<AppEntityMap>). Дальше тип Handler<typeof MyEndpoint> читает эту информацию и автоматически выводит типы второго параметра хендлера. Именно поэтому db внутри хендлера уже типизирован — без явных аннотаций. Если обратиться к несуществующему полю db.nonexistent или зарегистрировать в контейнере значение неправильного типа — TypeScript выдаст ошибку на этапе компиляции, а не в рантайме.
export const createTodoHandler: Handler<typeof CreateTodoEndpoint> = async (
{ body, principal },
{ db }
// db: DbContext<AppEntityMap> — тип выведен из DbToken автоматически
) => { ... };
Контейнер подключается к серверу через .services():
const server = createServer()
.services(svc => configureDI(svc, config))
// ...
.listen(3000);
Исчерпывающая проверка хендлеров
Самое важное при регистрации хендлеров — функция mapHandlers. Она требует, чтобы для каждого эндпоинта в контракте был указан соответствующий хендлер. Если хоть один пропустить — TypeScript выдаст ошибку компиляции. Это гарантирует, что ни один эндпоинт не окажется без обработчика.
const mapping = mapHandlers(endpoints, {
todos: {
list: listTodosHandler,
create: createTodoHandler,
get: getTodoHandler,
delete: deleteTodoHandler
// Пропустить любой из них = ошибка TypeScript
}
});
const server = createServer()
.use(corsMiddleware) // middleware для CORS-заголовков
.use(requestLogMiddleware) // middleware для логирования запросов
.services(svc => configureDI(svc, config)) // регистрируем DI-контейнер
.useAuthentication({ // подключаем JWT-аутентификацию
defaultScheme: 'jwt',
schemes: [jwtScheme(...)]
})
.useAuthorization() // включаем проверку .authorize() на эндпоинтах
.withHealthcheck() // добавляет GET /health
.handleAll(mapping); // регистрирует все хендлеры из mapHandlers
server.listen(3000);
Несколько встроенных возможностей сервера, которые подключаются одной строкой:
.withHealthcheck()— добавляетGET /health, который возвращает200 OKкогда сервер готов принимать запросы. Удобно для Docker/Kubernetes readiness-проб..useBatching()— включает поддержку пакетных запросов черезPOST /__batch: клиент может отправить несколько запросов в одном HTTP-вызове, что снижает latency на медленных соединениях. Клиентский middlewarededupe()автоматически использует этот механизм при наличии нескольких параллельных запросов.
Типизированные хендлеры
Тип Handler выводит из эндпоинта всё необходимое: какие поля есть в body, query, params и principal. Писать касты не нужно.
import { type Handler, ActionResult } from '@cleverbrush/server';
import { type CreateTodoEndpoint } from './endpoints.js';
export const createTodoHandler: Handler<typeof CreateTodoEndpoint> = async (
{ body, principal },
{ db }
) => {
const todo = await db.todos.insert({
title: body.title, // string — выведено из CreateTodoBodySchema
completed: false,
userId: principal.userId // number — выведено из PrincipalSchema
});
return ActionResult.created(todo, `/api/todos/${todo.id}`);
};
IDE подсказывает все поля body и principal с правильными типами. Если схема изменится — хендлеры, обращающиеся к удалённым полям, перестанут компилироваться.
Важная деталь: в тело хендлера выполнение попадает только после успешной валидации. body, query и параметры пути автоматически проверяются по схемам из контракта ещё до вызова хендлера. Если данные не прошли валидацию — сервер вернёт 422 Unprocessable Entity в формате Problem Details с описанием каждой ошибки, и хендлер вызван не будет. Это значит, что внутри хендлера body.title гарантированно является непустой строкой — именно такой, как описано в CreateTodoBodySchema.
ActionResult предоставляет удобные фабричные методы: ActionResult.ok(data), ActionResult.created(data, location), ActionResult.notFound(body), ActionResult.forbidden(body), ActionResult.noContent() и т.д. Для нестандартных ответов можно использовать new StatusCodeResult(statusCode, body).
Ошибки и Problem Details
Когда в хендлере нужно прервать выполнение с HTTP-ошибкой, используются классы HttpError:
import { NotFoundError, ForbiddenError, ConflictError } from '@cleverbrush/server';
throw new NotFoundError({ message: 'Todo not found' });
throw new ForbiddenError({ message: 'Access denied' });
throw new ConflictError({ message: 'Already completed' });
Все ошибки автоматически сериализуются в формат RFC 9457 Problem Details с Content-Type: application/problem+json. Ошибки валидации (некорректный body или query) также возвращаются в этом формате с деталями по каждому полю.
Автоматическая генерация OpenAPI
Отдельный пакет @cleverbrush/server-openapi генерирует спецификацию OpenAPI 3.1 непосредственно из зарегистрированных эндпоинтов — никаких аннотаций или декораторов не требуется. Схемы конвертируются в JSON Schema автоматически.
import { generateOpenApiSpec } from '@cleverbrush/server-openapi';
const spec = generateOpenApiSpec({
server,
info: {
title: 'Todo API',
version: '1.0.0'
},
servers: [{ url: 'http://localhost:3000' }]
});
// Сервируем как обычный JSON-эндпоинт
server.handle(
endpoint.get('/openapi.json'),
() => spec
);
Типы ответов, параметры пути, query-параметры, тела запросов и ответов — всё попадает в спецификацию из тех же схем, что используются для валидации. Один источник правды.
Для WebSocket-подписок пакет @cleverbrush/server-openapi также умеет генерировать AsyncAPI 2.x-спецификацию через serveAsyncApi().
Клиент без кодогенерации
На стороне клиента тот же контракт превращается в типизированный HTTP-клиент через createClient(). Никакой кодогенерации — типы выводятся из контракта в момент компиляции через Proxy.
import { createClient } from '@cleverbrush/client';
import { api } from '@some-shared-library/contract';
const client = createClient(api, {
baseUrl: 'https://api.example.com',
getToken: () => localStorage.getItem('token')
});
// Вызов — полностью типизирован
const todos = await client.todos.list({ query: { page: 1 } });
// ^? TodoResponse[]
const todo = await client.todos.get({ params: { id: 42 } });
// ^? TodoResponse
const created = await client.todos.create({
body: { title: 'Купить молоко' }
});
// ^? TodoResponse (201)
Если изменить схему запроса или ответа в контракте — IDE немедленно покажет все места, где код клиента перестал соответствовать.
Клиент поддерживает middleware-цепочки для retry, timeout, дедупликации, кэширования и батчинга запросов:
import { createClient } from '@cleverbrush/client';
import { retry } from '@cleverbrush/client/retry';
import { timeout } from '@cleverbrush/client/timeout';
import { dedupe } from '@cleverbrush/client/dedupe';
const client = createClient(api, {
baseUrl: BASE_URL,
getToken: () => loadToken(),
middlewares: [
retry({ limit: 2, retryOnTimeout: true }),
timeout({ timeout: 10_000 }),
dedupe()
]
});
React и TanStack Query
Для React-приложений есть отдельный вход @cleverbrush/client/react. Там createClient возвращает «унифицированный» клиент, у которого каждый метод одновременно является и вызываемой функцией, и источником TanStack Query хуков.
import { createClient } from '@cleverbrush/client/react';
import { api } from './contract.js';
export const client = createClient(api, {
baseUrl: '/api',
getToken: () => localStorage.getItem('token')
});
В компоненте:
function TodoList() {
// useQuery — данные, загрузка, ошибки
const { data: todos, isLoading } = client.todos.list.useQuery(
{ query: { page: 1 } }
);
// useMutation — с типизированным телом
const create = client.todos.create.useMutation();
const handleAdd = () => {
create.mutate({ body: { title: 'Новая задача' } });
};
if (isLoading) return <p>Загрузка…</p>;
return (
<ul>
{todos?.map(t => <li key={t.id}>{t.title}</li>)}
<button onClick={handleAdd}>Добавить</button>
</ul>
);
}
Также доступны .useSuspenseQuery() и .useInfiniteQuery() с теми же принципами вывода типов. query key для TanStack Query формируется автоматически на основе имени группы, эндпоинта и аргументов.
WebSocket подписки
Для real-time обновлений контракт поддерживает подписки через endpoint.subscription(). Сервер отправляет события клиентам, а при двунаправленной подписке клиент может отправлять сообщения обратно.
Объявление в контракте:
live: {
todoUpdates: endpoint
.subscription('/ws/todos')
.outgoing(object({
action: string(),
todoId: number(),
title: string()
})),
chat: endpoint
.subscription('/ws/chat')
.incoming(object({ text: string() }))
.outgoing(object({ user: string(), text: string(), ts: number() }))
}
На клиенте — React-хук useSubscription из @cleverbrush/client/react:
import { useSubscription } from '@cleverbrush/client/react';
function LiveFeed() {
const { events, state } = useSubscription(
() => client.live.todoUpdates({ reconnect: { maxRetries: 10 } }),
{ maxEvents: 50 }
);
return (
<div>
<p>Статус: {state}</p>
{events.map((e, i) => (
<div key={i}>{e.action}: #{e.todoId} — {e.title}</div>
))}
</div>
);
}
Для двунаправленного канала (чат):
function Chat() {
const { events, state, send } = useSubscription(
() => client.live.chat({ reconnect: { maxRetries: 5 } })
);
return (
<div>
{events.map((e, i) => (
<p key={i}><b>{e.user}:</b> {e.text}</p>
))}
<button onClick={() => send({ text: 'Привет!' })}>
Отправить
</button>
</div>
);
}
Хук управляет жизненным циклом соединения автоматически: подключается при монтировании, отключается при размонтировании, поддерживает переподключение.
Итоги
Вся цепочка от схемы до браузера строится из одного источника правды:
@cleverbrush/schema → валидация + вывод типов
@cleverbrush/server/contract → defineApi(), endpoint builder, route()
@cleverbrush/server → сервер с DI, авторизацией, батчингом
@cleverbrush/server-openapi → OpenAPI 3.1 без аннотаций
@cleverbrush/client → типизированный HTTP-клиент
@cleverbrush/client/react → TanStack Query хуки + WebSocket хук
Изменение схемы в контракте мгновенно расходится по всему коду: TypeScript покажет несоответствия на сервере, в клиенте и в компонентах — до запуска приложения.
Что дальше
В следующих статьях серии:
@cleverbrush/orm— лёгкая ORM на основе схем: типизированные запросы, миграции и связи без кодогенерации.@cleverbrush/log+@cleverbrush/otel— структурированное логирование и трассировка через OpenTelemetry, где схемы задают структуру лог-записей и span-атрибутов.
Ссылки
GitHub: https://github.com/cleverbrush/framework
Документация и playground: https://docs.cleverbrush.com
Демо-приложение (Todo, полный стек): https://docs.cleverbrush.com/demo
npm:
npm install @cleverbrush/server @cleverbrush/server-openapi
npm install @cleverbrush/clientБуду рад любой обратной связи — по API, документации, пропущенным фичам. Issues и PR приветствуются.






























