npm.io
0.3.1 • Published 2d ago

@toad-contracts/core

Licence
MIT
Version
0.3.1
Deps
1
Size
72 kB
Vulns
0
Weekly
0

@toad-contracts/core

API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend. The contract describes a route (its path, HTTP method, and request/response schemas) and serves as the single source of truth for both sides.

The backend implements the route against the contract. The client uses the same contract to make type-safe requests without duplicating configuration. This keeps documentation, validation, and types in sync across the boundary.

Schemas are any Standard Schema implementation (valibot, zod, arktype, and others). The contract logic only depends on the @standard-schema/spec interface, not on a specific library.

Using valibot? Prefer @toad-contracts/valibot. It re-exports everything here and adds withObjectKeys, which lets a valibot object schema satisfy the object-key introspection core needs for path-param schemas. See Path mapping below for the underlying mechanism.

Defining contracts

REST routes

A requestPathParamsSchema must expose its object keys so the route path can be built from the contract (see Path mapping). The Standard Schema interface does not expose keys, so wrap the schema with your adapter's helper, here withObjectKeys from @toad-contracts/valibot. Query, header, body, and response schemas need no wrapping.

import { defineApiContract, noBodyResponse } from "@toad-contracts/core";
import { withObjectKeys } from "@toad-contracts/valibot";
import { object, string, pipe, uuid } from "valibot";

// GET with path params
const getUser = defineApiContract({
  method: "get",
  requestPathParamsSchema: withObjectKeys(object({ userId: pipe(string(), uuid()) })),
  pathResolver: ({ userId }) => `/users/${userId}`,
  responsesByStatusCode: {
    200: object({ id: string(), name: string() }),
  },
});

// POST
const createUser = defineApiContract({
  method: "post",
  pathResolver: () => "/users",
  requestBodySchema: object({ name: string() }),
  responsesByStatusCode: {
    201: object({ id: string(), name: string() }),
  },
});

// DELETE with no response body
const deleteUser = defineApiContract({
  method: "delete",
  requestPathParamsSchema: withObjectKeys(object({ userId: pipe(string(), uuid()) })),
  pathResolver: ({ userId }) => `/users/${userId}`,
  responsesByStatusCode: {
    204: noBodyResponse(),
  },
});
Non-JSON responses

For responses that are not JSON, three wrappers record the response content-type in the contract and differ only in the JS type the client materializes the body into:

  • textResponse(contentType)string. Convenient for small text payloads (CSV, plain text).
  • blobResponse(contentType)Blob. Buffered; offers .text(), .arrayBuffer(), .stream().
  • streamResponse(contentType)ReadableStream<Uint8Array>. Zero buffering; stream large payloads directly, or wrap for convenience via new Response(body).text() / .blob() / .arrayBuffer().
import {
  defineApiContract,
  textResponse,
  blobResponse,
  streamResponse,
} from "@toad-contracts/core";

const exportCsv = defineApiContract({
  method: "get",
  pathResolver: () => "/export.csv",
  responsesByStatusCode: { 200: textResponse("text/csv") },
});

const downloadPhoto = defineApiContract({
  method: "get",
  pathResolver: () => "/photo.png",
  responsesByStatusCode: { 200: blobResponse("image/png") },
});

// large export streamed without buffering the whole body in memory
const streamExport = defineApiContract({
  method: "get",
  pathResolver: () => "/export-large.csv",
  responsesByStatusCode: { 200: streamResponse("text/csv") },
});
SSE and dual-mode routes

Use sseResponse() inside responsesByStatusCode to define SSE event schemas. For endpoints that respond with either JSON or an SSE stream depending on the Accept header, use anyOfResponses() to declare both options on the same status code.

import { defineApiContract, sseResponse, anyOfResponses } from "@toad-contracts/core";
import { object, string } from "valibot";

// SSE-only
const notifications = defineApiContract({
  method: "get",
  pathResolver: () => "/notifications/stream",
  responsesByStatusCode: {
    200: sseResponse({
      notification: object({ id: string(), message: string() }),
    }),
  },
});

// Dual-mode: JSON response or SSE stream depending on Accept header
const chatCompletion = defineApiContract({
  method: "post",
  pathResolver: () => "/chat/completions",
  requestBodySchema: object({ message: string() }),
  responsesByStatusCode: {
    200: anyOfResponses([
      sseResponse({
        chunk: object({ delta: string() }),
        done: object({ finish_reason: string() }),
      }),
      object({ text: string() }),
    ]),
  },
});
Wildcard and default response keys

In addition to exact status codes, responsesByStatusCode accepts OpenAPI-style range keys ('1xx''5xx') and 'default' as fallbacks. Lookup precedence at runtime: exact code → range key → 'default'.

import { defineApiContract } from "@toad-contracts/core";
import { object, array, string, unknown } from "valibot";

const listItems = defineApiContract({
  method: "get",
  pathResolver: () => "/items",
  responsesByStatusCode: {
    "2xx": object({ items: array(string()) }),
    "4xx": object({ message: string() }),
  },
});

const flexible = defineApiContract({
  method: "get",
  pathResolver: () => "/data",
  responsesByStatusCode: {
    200: object({ data: unknown() }),
    default: object({ error: string() }),
  },
});

The '2xx' range key participates in SSE detection and success/error type narrowing exactly like explicit 2xx codes. 'default' is split into a success half (SuccessfulHttpStatusCode) and a non-success half in InferSseClientResponse / InferNonSseClientResponse so error narrowing stays correct regardless of the actual status code.

Path mapping

mapApiContractToPath(contract) turns a contract into an Express/Fastify-style path pattern ("/users/:userId"); describeApiContract(contract) returns "GET /users/:userId". Both are single-argument.

To build the pattern, core needs the path-param field names. The Standard Schema spec is validation-only and does not expose an object schema's keys at runtime, so core defines a small ObjectKeysCarrier interface and requires a requestPathParamsSchema to implement it:

export interface ObjectKeysCarrier {
  getObjectKeys: () => readonly string[];
}

The dependency is inverted: core depends only on this interface, never on a concrete schema library, and adapters satisfy it. @toad-contracts/valibot exposes withObjectKeys, which reads valibot's .entries:

import { mapApiContractToPath, describeApiContract } from "@toad-contracts/core";
import { withObjectKeys } from "@toad-contracts/valibot";
import { object, string } from "valibot";

const getUser = defineApiContract({
  method: "get",
  requestPathParamsSchema: withObjectKeys(object({ userId: string() })),
  pathResolver: ({ userId }) => `/users/${userId}`,
  responsesByStatusCode: { 200: object({ id: string() }) },
});

mapApiContractToPath(getUser); // "/users/:userId"
describeApiContract(getUser); // "GET /users/:userId"

Without an adapter, implement the interface yourself by attaching getObjectKeys to any Standard Schema:

const pathParams = Object.assign(object({ userId: string() }), {
  getObjectKeys: () => ["userId"],
});

Type utilities

  • InferNonSseSuccessResponses<T>: TypeScript output type of all non-SSE 2xx responses. JSON schemas → StandardSchemaV1.InferOutput<T>, textResponsestring, blobResponseBlob, streamResponseReadableStream<Uint8Array>, ContractNoBody/NoBodyResponseundefined, sseResponsenever (excluded). anyOfResponses entries are unpacked before mapping.
  • InferJsonSuccessResponses<T>: union of Standard Schema types for all JSON 2xx entries.
  • InferSseSuccessResponses<T>: SSE event schema map type from a responsesByStatusCode map.
  • HasAnySseSuccessResponse<T>, HasAnyJsonSuccessResponse<T>, HasAnyNonSseSuccessResponse<T>: boolean checks over 2xx entries.
  • ContractResponseMode<T>: 'dual' (SSE + non-SSE), 'sse' (SSE-only), or 'non-sse'.
  • AvailableResponseModes<T>: union of 'json' | 'sse' | 'blob' | 'text' | 'stream' | 'noContent'.
  • SseEventOf<S>: discriminated union of SSE events inferred from a schemaByEventName map, aligned with the browser MessageEvent shape: { type, data, lastEventId, retry }.

Client types

Primarily consumed by HTTP client implementations.

  • ClientRequestParams<TApiContract, TIsStreaming>: infers the request parameter object (pathParams, body, queryParams, headers, optional pathPrefix, and streaming for dual-mode contracts).
  • InferSseClientResponse<TApiContract>: discriminated union of { statusCode, headers, body } for SSE mode. Exact 2xx codes and '2xx' yield AsyncIterable<SseEventOf<...>>.
  • InferNonSseClientResponse<TApiContract>: same shape for non-SSE mode. Exact 2xx codes and '2xx' yield JSON / string / Blob / ReadableStream<Uint8Array> / null (SSE excluded).
  • DefaultStreaming<T>: true for SSE-only contracts, false otherwise.

Contract type aliases

  • ApiContract: union of all contract variants (GetApiContract | DeleteApiContract | PayloadApiContract).
  • GetApiContract, DeleteApiContract, PayloadApiContract: individual variants.
  • RequestQuerySchema, RequestHeaderSchema, ResponseHeaderSchema: Standard Schema object-schema constraints for generic helpers.
  • RequestPathParamsSchema: a Standard Schema that also implements ObjectKeysCarrier.
  • ObjectKeysCarrier: the { getObjectKeys(): readonly string[] } capability a path-param schema must add so mapApiContractToPath can read its keys (see Path mapping).

Utility functions

  • mapApiContractToPath(contract): Express/Fastify-style path pattern, e.g. "/users/:userId".
  • describeApiContract(contract): human-readable "METHOD /path" string.
  • hasAnySuccessSseResponse(contract): true when any 2xx entry is an SSE response (including inside anyOfResponses).
  • getSseSchemaByEventName(contract): extracts SSE event schemas, or null when none are present.
  • resolveResponseEntry(...) / resolveContractResponse(...): resolve a status code + content-type to a concrete ResponseKind ('json' | 'text' | 'blob' | 'stream' | 'sse' | 'noContent').

Validation

Vendor-neutral helpers for running a value through a Standard Schema, the equivalent of a schema library's parse. They back request and response validation in @toad-contracts/frontend-http-client and the mock helpers in @toad-contracts/testing, so every package validates the same way.

  • validate(schema, value): async. Returns the parsed output (unknown keys stripped, transforms applied), awaiting schemas whose ~standard.validate resolves asynchronously. Throws SchemaValidationError on failure.
  • validateSync(schema, value): synchronous variant. Throws a TypeError when the schema validates asynchronously, for callers that cannot await (e.g. buffered mock helpers).
  • SchemaValidationError: thrown on validation failure. Carries the raw StandardSchemaV1.Issue[] in its issues property; accepts an optional custom message.
import { validate, validateSync, SchemaValidationError } from "@toad-contracts/core";
import { object, string } from "valibot";

const schema = object({ id: string() });

await validate(schema, { id: "1", extra: "dropped" }); // -> { id: "1" }
validateSync(schema, { id: "1" }); // -> { id: "1" }

try {
  await validate(schema, { id: 42 });
} catch (error) {
  if (error instanceof SchemaValidationError) {
    console.error(error.issues);
  }
}

Module augmentation

To enforce stricter typing on metadata:

// file -> apiContracts.d.ts
import "@toad-contracts/core";

declare module "@toad-contracts/core" {
  interface CommonRouteDefinitionMetadata {
    myTestProp?: string[];
    mySecondTestProp?: number;
  }
}

Keywords