Creating the Client
import { ApiClient } from "@tahanabavi/typefetch";
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
},
contracts,
);
client.init();
const api = client.modules;Always call client.init() before using client.modules.
Client Configuration
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
token: "static-token",
tokenProvider: async () => "dynamic-token",
useMockData: false,
mockDelay: {
min: 200,
max: 1000,
},
},
contracts,
);| Option | Type | Description |
|---|---|---|
baseUrl | string | Base API URL |
token | string | Static bearer token |
tokenProvider | () => string | Promise<string> | Dynamic token provider |
useMockData | boolean | Enables mock mode |
mockDelay | { min: number; max: number } | Simulated mock latency |
When both token and tokenProvider are provided, tokenProvider takes priority.
Transports
An endpoint's transport decides which wire it travels over. HTTP is built in
and is the default, so omitting transport means "http" and every contract
written before v2.0.0 is unchanged.
export const contracts = {
user: {
// http — the default; no `transport` key needed
getUser: {
method: "GET",
path: "/users/:id",
request: z.object({ path: z.object({ id: z.string() }) }),
response: User,
},
// grpc — needs @tahanabavi/typefetch-grpc
syncUser: {
transport: "grpc",
service: "user.v1.UserService",
rpc: "SyncUser",
request: z.object({ id: z.string() }),
response: User,
deadlineMs: 5_000,
},
// graphql — needs @tahanabavi/typefetch-graphql
userProfile: {
transport: "graphql",
operation: "query",
root: "user",
request: z.object({ id: z.string() }),
response: User,
},
},
};Registering
Adapters are passed explicitly at the setup site, so an app that never registers a transport never ships a byte of it:
import { ApiClient } from "@tahanabavi/typefetch";
import { graphqlTransport } from "@tahanabavi/typefetch-graphql";
import { grpcTransport } from "@tahanabavi/typefetch-grpc";
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
transports: [
graphqlTransport({ url: "https://api.example.com/graphql" }),
grpcTransport({ baseUrl: "https://grpc.example.com" }),
],
},
contracts,
);
client.init();A route naming a transport with no adapter registered fails at init() with the
endpoint id in the message — never on the first call in production. Each adapter
also validates its own routes there, so a gRPC endpoint missing service, or a
GraphQL response schema whose selection set cannot be generated, stops the client
being built.
Why the type only appears once installed
Transports merge themselves into an open registry:
// inside @tahanabavi/typefetch-grpc
declare module "@tahanabavi/typefetch" {
interface TransportRegistry {
grpc: { service: string; rpc: string; deadlineMs?: number; codec?: GrpcCodec };
}
}So transport: "grpc" is a type error until the package is a dependency, and
once it is, the route is fully checked — including that a gRPC route may not
carry path, method, bodyType or responseType. None of those mean anything
for a unary RPC.
What is shared
Everything except the four things that genuinely differ per wire. Retry, auth
and token providers, timeout, AbortSignal, mock mode, forced mocks,
instrument() events and overrides, and the entire middleware chain are
transport-independent.
endpointId is also unchanged, which is why typefetch-query-core and
type-devtools work with every transport without knowing any of them exist.
Identifying a route
Tooling must never read method or path directly, since those exist only on
HTTP routes. Ask the transport instead:
import { describeEndpoint } from "@tahanabavi/typefetch";
describeEndpoint(contracts.user.syncUser);
// { protocol: "gRPC", operation: "unary", target: "user.v1.UserService/SyncUser" }Capabilities
A transport declares what it cannot do, so the client warns rather than silently
doing nothing. onUploadProgress on a gRPC or GraphQL route logs a warning
instead of leaving a progress bar frozen at zero.
driver — pinning the sender on an http route
fetch cannot report upload progress, so the client switches to
XMLHttpRequest for any request that asks for it. driver makes that choice
explicit per endpoint:
uploadAvatar: {
method: "POST",
path: "/avatar",
driver: "xhr", // always XHR, so progress works without a per-call handler
request, response,
}driver | Behaviour |
|---|---|
"auto" (default) | fetch, switching to XHR only when a call asks for upload progress |
"fetch" | Always fetch — pins the modern path when a proxy or polyfill makes the swap undesirable |
"xhr" | Always XMLHttpRequest where it exists, falling back to fetch with a one-time warning |
XHR has no equivalent for cache, mode, redirect, referrerPolicy,
integrity or duplex, so those RequestInit fields are dropped on the XHR
path rather than silently misapplied.
Type Inference
TypeFetch infers endpoint input and output types automatically from Zod schemas.
const user = await api.user.getUser({
path: {
id: "123",
},
});user is inferred as:
{
id: string;
name: string;
}Invalid input fails at compile time when possible and at runtime through Zod validation.
Example:
// api/client.ts
import { ApiClient } from "@tahanabavi/typefetch";
import { contracts } from "./contracts";
export const client = new ApiClient(
{
baseUrl: import.meta.env.VITE_API_URL,
tokenProvider: async () => localStorage.getItem("token") ?? "",
},
contracts,
);
client.init();
export const api = client.modules;