Skip to content
TypeWire

@tahanabavi/typefetch

documents v1.10.0

The typed HTTP client every other package is built around.

Creating the Client

ts
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

ts
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
token: "static-token",
tokenProvider: async () => "dynamic-token",
useMockData: false,
mockDelay: {
min: 200,
max: 1000,
},
},
contracts,
);
OptionTypeDescription
baseUrlstringBase API URL
tokenstringStatic bearer token
tokenProvider() => string | Promise<string>Dynamic token provider
useMockDatabooleanEnables 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.

ts
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:

ts
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:

ts
// 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:

ts
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:

ts
uploadAvatar: {
method: "POST",
path: "/avatar",
driver: "xhr", // always XHR, so progress works without a per-call handler
request, response,
}
driverBehaviour
"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.

ts
const user = await api.user.getUser({
path: {
id: "123",
},
});

user is inferred as:

ts
{
id: string;
name: string;
}

Invalid input fails at compile time when possible and at runtime through Zod validation.


Example:

ts
// 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;