Search docs

Jump between documentation pages.

Browse docs

Typed clients

DaloyJS ships two ways to call your API with full type-safety. Use whichever fits your consumer.

1. In-process typed client (zero codegen)

For TypeScript consumers in the same monorepo (tests, internal tools, Next.js server actions):

ts
import { createClient } from "@daloyjs/core/client";
import { App } from "@daloyjs/core";
import { z } from "zod";

// Chain .route() calls so the App type accumulates each route.
const app = new App()
  .route({
    method: "GET",
    path: "/books/:id",
    operationId: "getBookById",
    request: { params: z.object({ id: z.string() }) },
    responses: {
      200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) },
      404: { description: "Not Found" },
    },
    handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
  });

const client = createClient(app, { baseUrl: "http://localhost:3000" });

const r = await client.getBookById({ params: { id: "1" } });
//    ^? { status: 200; body: { id: string; title: string } }
//      | { status: 404; body: ProblemJson }

if (r.status === 200) {
  console.log(r.body.title); // string, fully typed
}

The client is keyed by operationId, returns a discriminated union of {status, body, headers}, and infers everything from the route definition itself. No build step.

Inference requires method chaining. Each app.route(...) call returns a new App type that accumulates the route, so chain your registrations (new App().route(a).route(b)) and let TypeScript infer the variable's type. Two things erase inference and collapse the client back to a loose, untyped surface:

  • Annotating the instance with a bare const app: App = ... (or returning : App from a factory). The widening annotation discards the accumulated routes, so let the type be inferred instead.
  • Registering routes as separate statements (app.route(a); app.route(b);) on a previously-declared variable. The variable keeps its original type, so the new routes never reach the client.

If you import app from another module, export it without a widening annotation (export const app = new App().route(...)...;) so its inferred type, and the typed client, crosses the module boundary intact.

2. Hey API SDK (cross-language, cross-repo, build-time)

For consumers outside the monorepo or in other languages, generate a fully typed fetch SDK with @hey-api/openapi-ts.

Codegen pipeline
  1. 01Routesapp.route(...)
  2. 02generateOpenAPI@daloyjs/core/openapi
  3. 03openapi.jsonOpenAPI 3.1 spec on disk
  4. 04openapi-tsHey API generator
  5. 05Typed SDKsdk.gen.ts · types.gen.ts
  6. 06Consumerfully typed fetch calls
pnpm gen runs the whole chain: dump the spec from your routes, then let Hey API turn it into a typed fetch SDK. Re-run it whenever a route changes and the client stays in lockstep with the contract.
bash
pnpm add -D @hey-api/openapi-ts prettier tsx
ts
// openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";

export default defineConfig({
  input: "./generated/openapi.json",
  output: { path: "./generated/client", postProcess: ["prettier"] },
  plugins: ["@hey-api/client-fetch", "@hey-api/typescript", "@hey-api/sdk"],
});
json
// package.json
"scripts": {
  "gen:openapi": "node --import tsx scripts/dump-openapi.ts",
  "gen:client":  "openapi-ts",
  "gen":         "pnpm gen:openapi && pnpm gen:client"
}
bash
pnpm gen
# writes:
#   generated/openapi.json
#   generated/client/{client.gen.ts, sdk.gen.ts, types.gen.ts, index.ts}

Using the generated SDK

ts
import { client } from "./generated/client/client.gen.js";
import { getBookById } from "./generated/client/sdk.gen.js";

client.setConfig({ baseUrl: "https://api.example.com" });

const { data, error } = await getBookById({ path: { id: "1" } });
if (error) console.error(error);
else if (data) console.log(data.title);

Which one should I use?

Use casePick
Same-repo TypeScript caller (tests, internal tools)In-process createClient
Web app / mobile RN bundle in a separate repoHey API SDK
Non-TypeScript consumer (Python, Swift, Kotlin)OpenAPI doc + their preferred generator
Public SDK for third partiesHey API SDK, published as its own package

Coming from ts-rest?

ts-rest is a popular contract-first library that gives you end-to-end TypeScript types without codegen by sharing a contract (initContract) between an adapter-based server (Express, Fastify, NestJS, Next.js) and a fetch client (initClient). If you like that model, DaloyJS will feel familiar, with two differences.

First, in DaloyJS the route definition is the contract, there is no separate contract object to keep in sync. The in-process createClient shown above gives the same zero-codegen, shared-types experience for same-repo TypeScript callers.

Second, ts-rest's type safety is TypeScript-only and requires the client to import the contract. DaloyJS emits a first-class OpenAPI 3.1 spec and a Hey API SDK from the same routes, so consumers that can't import your types (other repos, other languages, public SDKs) are covered too. In ts-rest, OpenAPI is an optional add-on (@ts-rest/open-api). DaloyJS is also the server and runtime itself, portable across Node, Bun, Deno, Cloudflare, and Vercel, rather than a typing layer mounted on a separate framework.

ts-restDaloyJS
Contract sourceSeparate initContract objectThe route definition itself
Zero-codegen typed clientYes (initClient, TypeScript only)Yes (createClient, TypeScript only)
Cross-language / cross-repo clientsOpenAPI add-on (@ts-rest/open-api)OpenAPI 3.1 + Hey API SDK, first-class
ServerAdapter on Express / Fastify / NestJS / Next.jsBuilt-in, runtime-portable
Runtime validationStandard Schema (Zod / Valibot / ArkType)Standard Schema (Zod / Valibot / ArkType / TypeBox)
Security defaultsBring your ownBuilt-in headers, CSRF, rate limits, body limits, and more

Need a bigger contract to validate your generator output? Use the large fake REST demo as the stress case instead of a minimal tutorial app.