TypeScript client

voltro build api --target typescript — a standalone, publishable npm package for Node, Bun, Deno and the browser that speaks the publicApi surface profile-aware, as a Promise client without Effect and as an Effect service, with typed errors, pages, a 429 as a wait, idempotency keys and live queries over the socket.

The React web app gets createHooks, iOS and Android get a Swift or Kotlin SDK, and the third consumer the REST surface was built for — a Node service, a partner backend, a pipeline script — used to get a spec and a foreign generator. A generic generator produces types; what it cannot know is everything that makes the surface a Voltro surface: that urn:voltro:error:AccessDeniedError is a class you can narrow on, that a "2026-…" string is a Date because the schema says so, that Link: …; rel="next" is a page, that 429 with Retry-After is a wait, that a mutation declared idempotent may be sent twice under one key, that Sunset is a warning — and that the same queries come live over a WebSocket.

voltro build api --target typescript generates that client from the app's own effect/Schema values. The generator walks them once and writes what it learned as data: which fields are dates and how the schema puts them on the socket, which keys a snake_case profile renames, what every declared error carries. The package that comes out imports nothing from the app — it publishes as it is — and needs no schema library to decode with the same facts the server encodes with.

Generated clients use TypeScript 7 and Vite 8. Declaration generation with vite-plugin-dts also installs @typescript/typescript6, the official JavaScript compiler-API compatibility package. Keep this build dependency when updating generated clients; TypeScript 7 alone does not provide the previous compiler API.

Two flavors — the consumer picks

Entry What it is Installs
@acme/api-client The Promise client. createClient({ baseUrl, token }), await api.teams.list(), for await over pages and live queries. @voltro/api-client — no dependencies. No Effect anywhere.
@acme/api-client/effect The Effect service. const api = yield* ApiClient, a call an Effect typed with the app's errors, pages and live queries as Streams. effect and @voltro/api-client-effect, declared as optional peers — installed only by a consumer who imports this entry.

Both are one transport: the Effect flavor lifts the Promise client (Effect.tryPromise, Stream.fromAsyncIterable), so the decoders, the error classes, the retry rules and the socket live in one place — @voltro/api-client, which depends on nothing.

--flavor decides what the package carries: both (the default — the table above), plain (the Promise client only, no ./effect entry, no effect anywhere in package.json), effect (the Effect service at ., effect a peer).

The app decides

Whether there is a publishable client, what it is called, where it goes and which flavor it carries are the app owner's decisions, kept in app.config.ts:

// app.config.ts
export default defineApi({
  publicApi: {
    profile: 'standard',
    artifacts: ['openapi', 'typescript'],   // voltro dev keeps the package written
    client: { package: '@acme/api-client', out: 'sdk/ts', flavor: 'plain', license: 'MIT' },
  },
})

publicApi.client is what every generation starts from; artifacts: ['typescript'] lets voltro dev write the package on every boot beside rpcGroup.generated.ts (touching only files whose content changed), so a surface change shows up in the review diff as a change to the client. Without it the package is written on demand only. Change the decision later without editing the file by hand:

voltro api-client show                                                   # the decision, defaults filled in
voltro api-client set --name @acme/api-client --flavor plain --license MIT
voltro api-client set --flavor both --artifact on                        # switch flavors, let voltro dev keep it written
voltro api-client build                                                  # generate now, from the decision
voltro api-client build --flavor effect                                  # one run with another flavor; the file is unchanged

set edits publicApi.client (and artifacts) through the TypeScript AST, so a hand-written config keeps its comments and its shape; it refuses a publicApi that is not an object literal and says so, naming the file.

Generate

voltro build api --target typescript --out ./sdk/ts --name @acme/api-client
voltro build api --target typescript --out ./sdk/ts --name @acme/api-client --flavor plain --license MIT

Without flags the command reads publicApi.client; a flag overrides one field for this run.

Flag Meaning
--out <dir> Where the package is written. Default <appDir>/sdk/typescript. Anywhere — the package has no path into the app. The directory must be empty or a package voltro generated before; pointed at one holding anything else, the build refuses rather than overwriting it.
--name <pkg> The package name. Default @app/api-client.
--package-version <semver> The generated package's version. Default 0.1.0.
--flavor plain | effect | both Which client the package carries. Default both.
--license <SPDX> The license field. Without it npm warns at publish; the README says so.
--version <v2> Generate for ONE API version — the projections of v2 only; the manifest says so.
--base-url <url> The client's default base URL (the manifest's first server). Otherwise VOLTRO_PUBLIC_API_ORIGIN when set, else the caller passes baseUrl.

The output is a package: a .voltro-generated marker (the directory is written in full, so voltro dev prunes it from its watch instead of restarting on it; files keeps it out of what npm packs), package.json (ESM + CJS + .d.ts from pnpm build via vite), tsconfig.json, a README, and src/manifest.ts (every projection's method, path, parameters, version, security, the typed errors and their statuses; the profile; the servers; the wire shapes; the digest of the surface), types.ts (every model as an interface, named as the app exports the schema — export const Team = Schema.Struct(…) becomes interface Team, including a schema shared from a lib/ module the descriptors import — and a Procedures table indexed by tag), errors.ts (the app's declared errors as classes), index.ts (the Promise flavor) and effect.ts (the Effect flavor).

What the client cannot decode

The generator maps what has a JSON form. A schema kind that has none — a bigint, a symbol, an opaque declaration, a union it cannot narrow — becomes a raw JSON passthrough, and a build says so by tag and path:

2 shape(s) the client passes through as raw JSON — a schema kind with no JSON form (a bigint, a symbol, an opaque declaration) or an un-narrowable union:
  reports.get — output.payload
  reports.get — error.Boom.cause
A value under one of these is NOT decoded: a date there arrives as the string on the wire. Give it a schema with a JSON form to type it.

A surface that maps completely prints nothing — which is what makes the silence worth something. A discriminated union of structs maps, and is not reported.

Publish

cd sdk/ts
pnpm install
pnpm build                    # dist/: ESM + CJS + .d.ts
pnpm publish --access public  # or your registry

Nothing in the package reaches outside it: no relative import into the app, no dependency on what the app's descriptor graph imports. A partner installs @acme/api-client and gets @voltro/api-client beside it — and, only if they import ./effect, install effect and @voltro/api-client-effect themselves.

Use — the Promise flavor

import { createClient, AccessDeniedError } from '@acme/api-client'

const api = createClient({ baseUrl: 'https://api.example.com', token: process.env.API_TOKEN })

const teams = await api.teams.list()                      // typed from the descriptor's output
const team = await api.teams.getById({ teamId })          // a path parameter, when projected
try {
  await api.teams.rename({ teamId, name: 'Core' })        // Idempotency-Key sent automatically when the descriptor says idempotent
} catch (e) {
  if (e instanceof AccessDeniedError) { /* the app's own class, decoded from urn:voltro:error:AccessDeniedError */ }
}
for await (const page of api.teams.list.pages({ limit: 50 })) { /* every page, Link: rel="next" walked for you */ }
for await (const live of api.teams.list.subscribe()) { /* the current value and every change, over the socket */ }

Every method is grouped by the tag prefix (teams.listapi.teams.list), typed from the descriptor's input and output; an input with no required field is an optional parameter, a procedure without input takes none. Every call carries .pages(input) beside it, every query .subscribe(input). A rejection is the failure value itself — the app's own error class from errors.ts (instanceof and _tag narrow, status and requestId ride along) — or one of the client's own: RateLimitedError (429, with retryAfterMs, limit, remaining), TransportError (network, abort, a body that was not JSON), ApiHttpError (a refusal that is none of the declared errors: 400, 401, 403, 404, 410, 500 — with the URN's tag, the detail and the x-request-id), ProfileMismatchError and InputEncodeError.

Use — the Effect flavor

import { Effect } from 'effect'
import { ApiClient } from '@acme/api-client/effect'

const program = Effect.gen(function* () {
  const api = yield* ApiClient
  const teams = yield* api.teams.list()   // Effect<TeamsListOutput, AccessDeniedError | RateLimitedError | TransportError | …>
  return teams
}).pipe(Effect.provide(ApiClient.layer({ baseUrl, token })))

ApiClient is a service tag typed with the namespaces; ApiClient.layer(config) provides it and closes the socket when the scope ends, ApiClient.service(config) builds it without a layer. api.teams.list.pages(input) is a Stream of pages, api.teams.list.stream(input) the live query. The failure is the same class the Promise flavor rejects with, in the error channel — Effect.catchTag('AccessDeniedError', …) works.

Live queries, without Effect

subscribe speaks the api's rpc socket directly — the same protocol the browser client speaks through @effect/rpc (/ws, one JSON message per frame): the request with the bearer and the tenant in its headers, every chunk acknowledged before the server sends the next, a Ping every ten seconds and a reconnect when the Pong stays out. The first event is a snapshot, every later one a delta — an id-keyed row patch applied to the held rows — or an in-band error, which arrives as the app's own class. On a reconnect every open subscription is re-issued with voltro-resume-from, so the server replays only what was missed. The socket opens on the first subscribe and closes with the last one (or api.close()).

subscriptions: { wsUrl?, webSocketConstructor?, pingIntervalMs?, reconnect?: { initialMs, maxMs }, onStatus? } in the config. Node 22+ has a global WebSocket; hand in ws below that.

What the client knows

The profile decides The client does
dates: 'iso' / 'epoch-ms' RFC 3339 or epoch ↔ Date, at exactly the fields the schema declares — read from the wire shapes, never a regex over strings.
errors: 'problem-details' / 'tagged' The type URN (or the error tag) → the descriptor's own error class; extension members are its fields.
envelope: 'none' / 'data' / 'jsonapi' Unwrapped; a JSON:API document is folded back into the output's shape.
pagination: 'link-header' / 'body-cursor' pages() walks either.
naming: 'snake_case' Keys on the wire, camelCase in your code, path parameters included — the declared keys, the way the server's own view renames them.
etag: true If-None-Match from a per-URL cache; a 304 answers from it.

The profile is read from the bindings at generation time and checked at runtime: every projection answer carries x-voltro-rest-profile, and a client generated for standard that meets jsonapi refuses on the first call with a ProfileMismatchError — not on the first mis-parsed date.

Operation Config
Auth token (a string, or a function asked per request — rotation without a restart), tenantx-tenant, headers.
429 A RateLimitedError by default; retry: { on429: { maxWaitMs } } waits Retry-After and retries within the ceiling. In the Effect flavor the failure composes with any Schedule.
Idempotency An Idempotency-Key (UUID v7) on every mutation whose descriptor says idempotent: true; options.idempotencyKey pins one.
Timeouts, abort options.signal (Promise), interruption (Effect).
Tracing traceparent: () => string continues your trace; x-request-id rides on every failure.
Sunset Sunset / Deprecation on a response warns once per process per tag (onSunset to route it).
User-Agent <package>/<version> (voltro-ts <cli version>) — the line an operator reads to see who is old.

Tests without an API

import { createMockClient, manifest, errors, RateLimitedError } from '@acme/api-client'

const mock = createMockClient(manifest, errors)
mock.setData('teams.list', { items: [{ id: 't1', name: 'Core', createdAt: new Date() }], nextCursor: null })
mock.setError('teams.rename', new RateLimitedError({ tag: 'teams.rename', retryAfterMs: 60_000 }), 1)
mock.setPages('teams.list', [[/* page 1 */], [/* page 2 */], new RateLimitedError({ tag: 'teams.list', retryAfterMs: 60_000 })])
mock.push('teams.list', { items: [], nextCursor: null })   // every live subscription sees it

The same namespaces, the same error classes, the same pagination — so "what happens on a 429 on page three" is measured in the consumer's own suite, before it happens on staging. mock.calls records every call. An Effect test provides ApiClient.layerOver(mock).

Drift

The generated package carries the digest of the bindings it was built from (manifest.digest, also package.jsonvoltro.digest); regenerate when the surface moves, and let voltro check --public-api say what moved.