SDK
The SDK feature in Hyper Fetch provides a powerful, type-safe way to structure your API interactions. It allows
you to define your entire API surface as a typed object, which then uses recursive proxies to generate request
instances on-the-fly. This approach, inspired by tRPC, promotes a highly organized and maintainable codebase where
endpoints are accessed with an intuitive dot-notation.
- Type-Safe API: Full TypeScript coverage for endpoints, responses, and parameters.
- tRPC-like Syntax: Access endpoints with dot-notation (e.g.,
sdk.users.get()). - Zero Performance Overhead: Generates requests on-demand using proxies.
- Dynamic URL Support: Handle parameterized routes like
/users/:id. - Centralized Schema: Define your entire API structure in one place.
Initialization
To get started, you first need a Client instance. Then, you can use the createSdk function to build your SDK.
import { createClient, createSdk } from "@hyper-fetch/core";
// 1. Create a client instance
const client = createClient({ url: "http://localhost:3000" });
// 2. Define the schema for your SDK (more on this below)
type MyApiSchema = {
// ... schema definition
};
// 3. Create the SDK
const sdk = createSdk<typeof client, MyApiSchema>(client);
Defining the Schema
The core of the SDK is its schema. This is a TypeScript type that maps your API's structure. You define nested objects
for URL paths and then add properties for HTTP methods (get, post, put, delete, etc.).
Dynamic path segments are prefixed with a $ sign. For example, $userId becomes a /:userId parameter in the
endpoint.
RequestModel for schema leaves — not RequestInstanceWhen defining a schema you are describing one specific endpoint. Use RequestModel<{...}> — omitted fields stay
strict (unknown, undefined, Error, string, false) so the type system catches mistakes instead of silently
collapsing to any. The mental model below explains when to reach for each type.
import { RequestModel } from "@hyper-fetch/core";
// Schemas describe shape and types only. The client (and its adapter / global error)
// are injected automatically by createSdk(client) - no need to repeat per request.
type MyApiSchema = {
// GET /users
// GET /users?page=1&limit=10
users: {
$get: RequestModel<{
queryParams: { page: number; limit: number };
endpoint: "/users";
}>;
// GET /users/:userId
// DELETE /users/:userId
$userId: {
$get: RequestModel<{
response: { id: string; name: string };
endpoint: "/users/:userId";
}>;
$delete: RequestModel<{
response: { id: string; name: string };
endpoint: "/users/:userId";
}>;
// DELETE /users/:userId/posts/:postId
posts: {
$postId: {
$delete: RequestModel<{
response: { id: string; title: string };
endpoint: "/users/:userId/posts/:postId";
}>;
};
};
};
};
};
RequestModel vs RequestInstance — two mindsets
Hyper Fetch ships two complementary types for describing requests at the type level. They look similar but solve
opposite problems. Picking the right one is the difference between strict end-to-end safety and accidental any leaks.
RequestModel<{...}> | RequestInstance<{...}> | |
|---|---|---|
| Mindset | "This specific endpoint." | "Any request that matches this partial shape." |
| Use for | SDK schema leaves, single-endpoint definitions, request factories. | Generic constraints (<T extends ...>), reusable components / hooks / helpers that accept any request. |
| Omitted fields default to | Strict, non-any (unknown, undefined, Error, string, false). | any — on purpose, so any concrete Request satisfies the constraint. |
| Why those defaults? | Absence means "there is no payload / no query / no params". The type should reject mismatches. | Absence means "I don't care about that field". The type should accept anything. |
// MODEL — defining the /users endpoint exactly:
// response is an array of User
// no payload, no queryParams, no params
// any attempt to send extra data is a compile error.
type GetUsers = RequestModel<{ response: User[]; endpoint: "/users" }>;
// INSTANCE — accepting any request whose response is User[]:
// response is constrained
// payload, queryParams, error etc. can be ANY shape — caller picks.
function autocomplete<T extends RequestInstance<{ response: User[] }>>(request: T) {
return request.send();
}
If you find yourself writing RequestInstance inside a schema, that is a signal you wanted RequestModel. If you find
yourself writing RequestModel as a generic constraint on a reusable component, that is a signal you wanted
RequestInstance.
client per requestYou do not have to add client: AppClient to every RequestModel declaration. createSdk(client) rewrites the schema
type at the boundary so every Request leaf carries the actual client (and therefore its adapter and global error
type). The schema describes the API shape; the SDK supplies the runtime context.
How It Works: The Magic of Proxies
You might be wondering how the sdk object provides all these methods without you having to implement them. The magic
lies in Recursive Proxy Generation.
The sdk object is not a simple JavaScript object with predefined properties. Instead, it's a Proxy that dynamically
constructs requests as you access its properties. When you write sdk.users.$get, you're triggering a chain of proxy
getters:
sdk.users: The proxy intercepts this and internally notes downusersas the first part of the endpoint path. It then returns a new proxy to handle the next property..$get: This access is caught by the new proxy. It recognizes the$-prefixed key as an HTTP method, finalizes the request configuration (GET /users), and returns a fully configuredRequestinstance, ready to be used.
This approach has two major benefits:
- Performance: It's incredibly fast. The SDK doesn't generate all possible request instances upfront. It only creates a request when you explicitly access it. This means the startup time and memory footprint are minimal, even for very large and complex APIs.
- Developer Experience: It provides a fluid, tRPC-like experience where you can explore your API surface with dot-notation, guided by TypeScript's autocompletion, while the underlying complexity is handled for you.
Usage
Once your SDK is created, you can access your endpoints using the structure you defined in the schema. This returns a
Request instance, ready to be configured and sent.
Making a simple request
Here's how to get a list of users. The $get property corresponds to the GET method for the /users endpoint.
// GET /users?page=1&limit=10
const getUsersRequest = sdk.users.$get.setQueryParams({ page: 1, limit: 10 });
// Send the request
const { data, error } = await getUsersRequest.send();
Using dynamic segments
To fetch a specific user, you access the $userId path and use setParams to provide the userId.
// GET /users/123
const getUserRequest = sdk.users.$userId.$get.setParams({ userId: "123" });
const { data, error } = await getUserRequest.send();
if (data) {
console.log(data.name); // Typed as { id: string, name: string }
}
Nested requests
The SDK handles nested routes seamlessly.
// DELETE /users/123/posts/456
const deletePostRequest = sdk.users.$userId.posts.$postId.$delete.setParams({
userId: "123",
postId: "456",
});
const { data, error } = await deletePostRequest.send();
Using with React Hooks
The generated requests work seamlessly with @hyper-fetch/react hooks like useFetch and useSubmit.
import { useFetch } from "@hyper-fetch/react";
import { sdk } from "api/sdk"; // Assumes you've exported your sdk instance
function UserProfile({ userId }) {
const { data, loading } = useFetch(sdk.users.$userId.$get.setParams({ userId }));
if (loading) {
return <p>Loading...</p>;
}
return <h1>{data.name}</h1>;
}
SDK Configuration ($configure)
The $configure method lets you set per-request defaults that are automatically applied every time a request is
created through the SDK. Configuration supports two value formats: plain objects for common settings like retry and
cache, and functions for full access to every Request setter including mappers, mocking, and hooks.
Method-Specific Keys
Use dot-path keys that mirror the SDK accessor chain to target a specific request (endpoint + method):
const configuredSdk = sdk.$configure({
"users.$get": (request) => request.setResponseMapper(userListMapper).setCache(true),
"users.$userId.$get": (request) => request.setAuth(true).setCacheTime(60000),
"users.$userId.$delete": { auth: true, cancelable: true },
});
const getUsersRequest = configuredSdk.users.$get; // has mapper + cache
const deleteUserRequest = configuredSdk.users.$userId.$delete; // has auth + cancelable
Group Keys (Endpoint-Level)
Use endpoint-based keys to configure all methods on an endpoint at once:
const configuredSdk = sdk.$configure({
"*": { retry: 3 },
"/users": { cacheTime: 60000 },
"/users/*": { auth: true },
});
Function-Based Values
Functions receive the request instance and return a modified one. This gives access to every Request setter —
setResponseMapper, setMock, setPayloadMapper, setOptimistic, $hooks, and more:
const configuredSdk = sdk.$configure({
"auth.login.$post": (request) =>
request
.setResponseMapper(loginMapper)
.setHeaders({ "X-Custom": "value" }),
"users.$get": (request) =>
request
.setResponseMapper(userListMapper)
.setCache(true)
.setCacheTime(30000),
});
Plain Object Values
For common settings, use a plain object shorthand:
const configuredSdk = sdk.$configure({
"*": { retry: 3, retryTime: 1000 },
"/users": { cache: false },
});
| Key | Type | Description |
|---|---|---|
headers | HeadersInit | Default headers |
auth | boolean | Enable authentication |
cache | boolean | Enable/disable caching |
cacheTime | number | Cache TTL in milliseconds |
staleTime | number | Stale time in milliseconds |
retry | number | Number of retries |
retryTime | number | Delay between retries |
cancelable | boolean | Enable auto-cancellation |
queued | boolean | Enable queueing |
offline | boolean | Enable offline support |
deduplicate | boolean | Enable deduplication |
deduplicateTime | number | Deduplication window |
Application Order
When multiple keys match, they apply in this order (later overrides earlier):
"*"— Global defaults- Endpoint groups —
"/users","/users/*" - Method-specific —
"users.$get","users.$userId.$post"
Multi-file Configuration with createConfiguration
For large APIs, split configuration across files by domain using the createConfiguration factory.
Keys are validated against the SDK schema at compile time — typos are caught immediately.
import { createConfiguration } from "@hyper-fetch/core";
import type { MyApiSchema } from "./schema";
export const authConfig = createConfiguration<MyApiSchema>()({
"auth.login.$post": (request) => request.setResponseMapper(loginMapper),
"auth.register.$post": { retry: 0 },
});
import { createConfiguration } from "@hyper-fetch/core";
import type { MyApiSchema } from "./schema";
export const usersConfig = createConfiguration<MyApiSchema>()({
"/users": { cacheTime: 30000 },
"users.$userId.$get": (request) => request.setAuth(true).setStaleTime(5000),
});
import { authConfig } from "./auth.configure";
import { usersConfig } from "./users.configure";
export const configuredSdk = sdk.$configure(authConfig).$configure(usersConfig);
Pattern Matching
Configuration keys support four patterns:
"*"— Matches all endpoints (global defaults).- Exact endpoint — e.g.
"/users/:userId"matches all methods on that endpoint. - Wildcard — e.g.
"/users/*"matches/users/:userId,/users/:userId/posts, etc. - Dot-path — e.g.
"users.$get"matches onlysdk.users.$get(specific method + endpoint).
In this guide, you've learned how to leverage the Hyper Fetch SDK to create a type-safe, organized, and intuitive API
layer for your application. We've covered how to initialize the SDK, define a schema, use it to make requests, and
configure per-request defaults with $configure and createConfiguration. Configuration supports both plain-object
shorthand for common settings and function-based values for full access to mappers, mocking, hooks, and every Request
setter.
