Статья о том, как единый типизированный контракт позволяет получить проверяемые на этапе компиляции сервер, клиент и 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()
    });

    // сегмент урла /:id
    // здесь может быть значительно более сложная конструкция, например
    // route({ 
    //     categoryName: string(), 
    //     articleId: number().coerce() 
    // })`/category/$t{t => t.categoryName}/${t => t.articleId}`
    // что соответствует /category/:categoryName/:articleId
    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')
                // /api/todos/:id
                .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 на медленных соединениях. Клиентский middleware dedupe() автоматически использует этот механизм при наличии нескольких параллельных запросов.

Типизированные хендлеры

Тип 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 приветствуются.

Комментарии (4)


  1. DmitriyKuznetcov
    01.05.2026 05:23

    Звучит круто, автору респект и уважуха. Ещё бы поддержку для nest (не next)


    1. andrew_zol Автор
      01.05.2026 05:23

      Спасибо за отзыв! Контрибьютьтте :) Я nest не пользуюсь, поэтому едва ли сам добавлю в обозримом будущем :)


  1. SVAY
    01.05.2026 05:23

    Удивительно похоже на это.


    1. andrew_zol Автор
      01.05.2026 05:23

      согласен, схожесть есть.