yatta/client
A typed client built from your routes. No hand-written fetch calls.
19 exported symbols and 15 members, read from src/types/client.ts.
Construct
buildPath
functionSubstitutes `:name` segments and percent-encodes each value.
Encoding per segment rather than the whole path matters: an id containing a slash would otherwise add a path segment, and a value containing `?` or `#` would truncate the URL.
buildPath(template: string, params?: Record<string, unknown>): stringcreateClient
functionBuilds a client for a route table.
createClient<const R extends Routes>(routes: R, options?: ClientOptions): Client<R>const routes = defineRoutes({ getUser: route({ method: "get", path: "/users/:id", params: z.object({ id: z.string() }), response: UserSchema, }),}); const api = createClient(routes, { baseUrl: "https://api.example.com" });const user = await api.getUser({ params: { id: "42" } }); // typed from UserSchemadefineRoutes
functionDeclares a table of routes.
Also an identity function, for the same reason.
defineRoutes<const R extends Routes>(routes: R): Rroute
functionDeclares one route.
An identity function, so the literal keeps its exact types — including which validators were supplied and which were omitted, which is what makes the conditional types above resolve.
route<const D extends RouteDef>(def: D): DtoQueryString
functionSerialises a query object, dropping empty values and expanding arrays.
toQueryString(query: Record<string, unknown>): stringTypes
CallError
interfaceinterface CallError2 members
statuspropertystatus: numberbodypropertybody: unknown
ClientOptions
interfaceinterface ClientOptions6 members
baseUrlpropertybaseUrl?: stringOrigin the routes are called against, e.g. "https://api.example.com".
onRequestpropertyonRequest?: (request: Request) => voidCalled with the raw response before it is read.
The usual use is a cookie jar, so session auth works the same as it does server-side. Returning headers replaces the default handling.
onResponsepropertyonResponse?: (response: Response) => voidRead the response headers, for pagination cursors and rate-limit counters.
Called only on success.
expectpropertyexpect?: "json" | "text" | "stream"What a response should contain.
The body is parsed as JSON by default. Set "text" for a plain-text endpoint and "stream" to receive the ReadableStream untouched.
headerspropertyheaders?: Record<string, string>Sent with every request unless the call overrides it.
fetchpropertyfetch?: typeof globalThis.fetchPassed to fetch, so a client can share a cookie jar or agent.
RouteDef
interfaceOne route: where it goes, what it accepts, and what it returns.
Every validator is optional, because an endpoint legitimately has no body, and an optional field means the route can be declared from as little as a method and a path.
interface RouteDef7 members
methodpropertymethod: HttpMethodpathpropertypath: stringPath with `:name` segments, e.g. "/users/:id".
paramspropertyparams?: StandardSchemaV1querypropertyquery?: StandardSchemaV1bodypropertybody?: StandardSchemaV1responsepropertyresponse?: StandardSchemaV1<any>The response shape.
Optional, because a route with no declared response should not pretend to return `unknown` — the client falls back to the raw parsed JSON, which is the honest answer.
hasBodypropertyhasBody?: booleanWhether a request body may be sent. Defaults to true for post/put/patch.
Client
typeThe client object: one method per route, named by its key in the table.
`R[N]` is passed straight through. Intersecting it with `RouteDef` to force resolution — the usual trick for a deferred indexed access — collapsed each field's type into `Schema & Schema | undefined`, so every query and body came back `unknown`.
HttpMethod
typetype HttpMethod = "get" | "post" | "put" | "patch" | "delete" | "head"Method
typetype Method<T> = (args: MethodArgs<T>) => Promise<T extends { response: infer R; } ? R : unknown>MethodArgs
typeArguments accepted by one method.
Written as an intersection of conditionals rather than a mapped type over `keyof CallArgs<T>`. `keyof` applied to an unresolved conditional type is empty, so every argument vanished from the signature: the client worked at runtime while accepting nothing useful at compile time. Each part contributes its key only when the route declares that input, so passing a `body` to a route without one is an error rather than a field that is quietly dropped on the floor.
type MethodArgs<T> = (T extends { params: infer P; } ? { params: P; } : {}) & (T extends { query: infer Q; } ? { query: Q; } : {}) & (T extends { body: infer B; } ? { body: B; } : {}) & TransportOptionsMethodFor
typeOne method, inferred straight from a route definition.
Deliberately not built on : going through a named intermediate type left `R[N]` deferred inside the mapped type, so every signature stayed unresolved and the client rejected correct arguments at compile time while working fine at runtime. Inferring from the definition directly has one fewer layer to defer. Written as two overloads rather than one generic signature with a conditional return. The generic version looked equivalent and was not: TypeScript inferred the argument type from the call and never checked it against the route's schema, so a body of the wrong shape passed a clean compile and failed at runtime. Overloads check each case on its own. The second overload is what `raw: true` selects. With the validator switched off nothing is known about the result, so it is typed `unknown` — the one place the client will not claim to know a shape.
type MethodFor<D extends RouteDef> = [ RequiredArgKeys<D> ] extends [ never ] ? { (args?: Args<D> & { raw?: false; }): Promise<ResponseOf<D>>; (args: Args<D> & { raw: true; }): Promise<unknown>; } : { (args: Args<D> & { raw?: false; }): Promise<ResponseOf<D>>; (args: Args<D> & { raw: true; }): Promise<unknown>; }PathParams
typeNamed parameters a path declares, e.g. { id: string } for "/users/:id".
type PathParams<Path extends string> = Path extends `${infer _Head}:${infer Param}/${infer Rest}` ? { [K in Param | keyof PathParams<`/${Rest}`>]: string; } : Path extends `${infer _Head}:${infer Param}` ? { [K in Param]: string; } : {}Route
typeA route with its declared parts resolved to concrete types.
type Route<D extends RouteDef> = { method: D["method"]; path: D["path"]; params: D["params"] extends StandardSchemaV1 ? Infer<D["params"]> : PathParams<D["path"]>; query: D["query"] extends StandardSchemaV1 ? Infer<D["query"]> : undefined; body: D["body"] extends StandardSchemaV1 ? Infer<D["body"]> : undefined; response: D["response"] extends StandardSchemaV1 ? Infer<D["response"]> : unknown; hasBody: boolean; }TransportOptions
typeOptions every call accepts.
Separate from the route's own arguments so a route declaring no schemas still has a working signature.
type TransportOptions = { signal?: AbortSignal; headers?: Record<string, string>; raw?: boolean; }Other
Infer
unknownisStandardSchema
unknownisStandardSchemaStandardSchemaV1
unknownAuthConfig or JobPayload — declaring your schema once is enough for the rest to follow. See Typed keys.