A suite of type utilities for building strongly-typed APIs
🚧 Under active development
- 💪 Strongly-typed query, body, headers, response.
- 🗺️ Static path segments, as well as dynamic and wildcard parameters.
- 📦 Exposes core utilities for building typed fetch functions.
Install package:
# npm
npm install fetchdts
# pnpm
pnpm install fetchdtsDefine your API schema and create a strongly-typed fetch function:
import type { DynamicParam, Endpoint, TypedFetchInput, TypedFetchRequestInit, TypedFetchResponseBody, TypedResponse } from 'fetchdts'
// Define your API schema
interface APISchema {
'/users': {
[Endpoint]: {
GET: {
response: { id: number, name: string }[]
}
POST: {
body: { name: string, email: string }
response: { id: number, name: string, email: string }
}
}
[DynamicParam]: { // matches /users/123
[Endpoint]: {
GET: {
response: { id: number, name: string, email: string }
}
DELETE: {
response: { success: boolean }
}
}
}
}
}
// Create your typed fetch function
async function api<T extends TypedFetchInput<APISchema>>(
input: T,
init?: TypedFetchRequestInit<APISchema, T>,
) {
return fetch(input, init as RequestInit) as unknown as Promise<TypedResponse<TypedFetchResponseBody<APISchema, T>>>
}
// Use with full type safety
const users = await api('/users').then(r => r.json()) // Type: { id: number; name: string }[]
const user = await api('/users/123').then(r => r.json()) // Type: { id: number; name: string; email: string }Your API schema describes the structure of your endpoints using TypeScript interfaces:
interface Schema {
'/path': {
[Endpoint]: {
[HTTPMethod]: {
query?: { param: string } // Query parameters
body?: { data: any } // Request body
headers?: { auth: string } // Required headers
response: { result: any } // Response data
responseHeaders?: { 'x-rate-limit': string } // Response headers
}
}
}
}Static Paths: Exact string matches
interface Schema {
'/api/users': {
[Endpoint]: {
GET: { response: User[] }
}
}
}Dynamic Parameters: Single path segments
interface Schema {
'/api/users': {
[DynamicParam]: { // matches /api/users/123, /api/users/abc, etc.
[Endpoint]: {
GET: { response: User }
}
}
}
}Wildcard Parameters: Zero or more path segments
interface Schema {
'/api': {
[WildcardParam]: { // matches /api, /api/anything, /api/nested/deep
[Endpoint]: {
GET: { response: any }
}
}
}
}Matching order: a static segment is preferred to a dynamic parameter, and a dynamic parameter to
a wildcard, as in a router. Where the more specific match does not answer the method being
requested, the less specific one is used, so a POST-only static route and a GET wildcard sibling
are both reachable at the same path:
interface Schema {
'/api/blog': {
'/thing': { [Endpoint]: { POST: { response: D } } }
[WildcardParam]: { [Endpoint]: { GET: { response: C } } }
}
}
type A = TypedFetchResponseBody<Schema, '/api/blog/thing', 'POST'> // D
type B = TypedFetchResponseBody<Schema, '/api/blog/thing', 'GET'> // CA query string, a fragment and a trailing slash are ignored when a path is resolved, so
'/api/users?page=2' resolves as '/api/users' does.
fetchdts uses special symbols to define different types of route matching:
import { DynamicParam, Endpoint, WildcardParam } from 'fetchdts'
interface Schema {
// Endpoint: Marks where HTTP methods are defined
[Endpoint]: {
GET: { response: Data }
POST: { body: Input, response: Data }
}
// DynamicParam: Matches a single path segment (e.g. the `123` of /users/123)
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
}
}
// WildcardParam: Matches the rest of the path, which may be empty (e.g. the `a/b.txt` of /files/a/b.txt)
[WildcardParam]: {
[Endpoint]: {
GET: { response: File }
}
}
}All standard HTTP methods are supported:
GET,POST,PUT,DELETE,PATCHOPTIONS,HEAD,CONNECT,TRACE
Methods may be written in either case ('POST' or 'post'), as a router compares them
case-insensitively. A route registered for GET also answers HEAD, as it does in h3, which serves
the GET handler and discards the body; an endpoint that registers HEAD itself is used in
preference.
interface RESTSchema {
'/api/users': {
[Endpoint]: {
GET: { response: User[] }
POST: { body: CreateUser, response: User }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
PUT: { body: UpdateUser, response: User }
PATCH: { body: Partial<UpdateUser>, response: User }
DELETE: { response: { success: boolean } }
}
}
}
}Extracts valid URL paths from your schema:
type ValidPaths = TypedFetchInput<APISchema>
// Result: '/users' | '/users/${string}'A parameter is a ${string} placeholder, and ${string} may contain a slash, so a member covering a
parameter is a pattern that also matches a path with extra segments: '/users/1/2' is assignable to
`/users/${string}`. The union is what gives an editor its completions; ValidFetchInput is what
rejects a path the schema does not resolve.
The shape any path may take, for use as the constraint of the type parameter a path is inferred into:
declare function $fetch<T extends AnyFetchPath>(input: T /* ... */): unknownConstrain to this rather than to string, which loses the path of useFetch(ref('/api/users')):
ref() and computed() infer their own type parameter from the argument, and a literal widens where
the type it is inferred into is constrained by string and keeps its literal type where the
constraint is a pattern. A direct literal, a getter and a pre-declared Ref<'/api/users'> are
unaffected either way, and a union of paths has the same effect as a pattern, so a signature
constrained to TypedFetchInput<Schema> does not need this as well.
Whether a path resolves, for use in parameter position:
declare function $fetch<T extends AnyFetchPath, M extends AnyHTTPMethod = 'GET'>(
input: T & ValidFetchInput<Schema, T, M>,
init?: TypedFetchRequestInit<Schema, T, M> & { method?: M },
): TypedFetchResponseBody<Schema, T, M>This is an alternative to constraining the input with TypedFetchInput, which materialises the
union of every path in the schema. Validating one path costs no more than its depth, where checking
a union costs the size of the schema, so on a large generated schema this is substantially cheaper:
at 1500 routes and 200 call sites it checks in around 0.4s where the union takes around 3s
(pnpm bench --routes 1500 --calls 200 [--valid-input]). It accepts a query string or a fragment
without enumerating either, and narrows by method.
A path that resolves gives unknown, which leaves the parameter as T. One that does not gives a
type no string satisfies, whose single key carries the reason, so the failure reads as:
Argument of type '"/api/nope"' is not assignable to parameter of type
'"/api/nope" & { "fetchdts: no GET route matches '/api/nope'": never }'
The text is a diagnostic rather than API, and may change.
Put it on every signature that admits a path, including one already constrained by
TypedFetchInput: the union carries no method, so a signature constrained by it alone accepts a path
the schema knows for a method that path does not answer.
An input that is not a path, such as a Request or a URL, which fetch also takes, carries nothing to
check and is accepted, so a client admitting them can validate every signature without rejecting
them.
Every member of a union of paths must resolve, so a value narrowed to one of several paths cannot
pass on the strength of one of them. A path known only to be a string matches nothing and is
rejected; if callers should be able to request an arbitrary path, add a signature taking string and
returning unknown, so that the escape hatch is explicit.
The trade-off is completion: a validator has no literals for an editor to offer, so put the union in the constraint alongside it, as Typing a Fetch Function shows.
Provides typed request options for a specific path:
// For paths requiring body/headers/query parameters
await api('/users', {
method: 'POST',
body: { name: 'John' }, // ✅ Typed based on schema
headers: { authorization: 'Bearer token' }
})Pass Method where it is known. The methods a path answers depend on it, since a path may reach one
endpoint for one method and a different one for another, so a method-agnostic init describes only
what every reachable endpoint has in common:
type A = TypedFetchRequestInit<Schema, '/users', 'POST'>['body'] // { name: string }
type B = TypedFetchRequestInit<Schema, '/users'>['body'] // BodyInit | null | undefinedReturns the typed response body for a given path:
const response = await api('/users/123')
// Type automatically inferred from schemaThe methods a path answers, in either case, or every method where it resolves to nothing. Constrain a signature's
method parameter with it (method?: M & TypedFetchMethods<Schema, T>) to reject a method the path
does not answer, which a path union cannot express on its own.
The shape an endpoint declares for one field of the request, or a fallback where it declares none, plus whether the field is required.
Use these instead of TypedFetchRequestInit where the options type has to be extensible. A whole
init is built from conditionals, and an interface may only extend a type whose members are statically
known, so interface Options<T> extends Omit<TypedFetchRequestInit<Schema, T>, 'method'> does not
compile (TS2312) as soon as the path is generic. Writing the keys out avoids it:
interface Options<Path extends string, Method extends AnyHTTPMethod> {
body?: TypedFetchRequestBody<Schema, Path, Method>
query?: TypedFetchRequestQuery<Schema, Path, Method>
headers?: TypedFetchRequestHeaders<Schema, Path, Method>
}
// and a consumer of yours can extend it
interface MyOptions<Path extends string, Method extends AnyHTTPMethod> extends Options<Path, Method> {
retries?: number
}TypedFetchRequires<Schema, Path, Method, 'body' | 'query' | 'headers'> reports whether a field is
required, for making the corresponding member required at a call signature.
Pass never as the fallback where you need to tell "the endpoint declared this" from "it did not".
That is what lets a client widen only the declared case, which is what request headers want: an
endpoint declares the headers it requires, not the only ones a request may carry.
type Declared<Path extends string, Method extends AnyHTTPMethod> = TypedFetchRequestHeaders<Schema, Path, Method, never>
type Headers<Path extends string, Method extends AnyHTTPMethod> = [Declared<Path, Method>] extends [never]
? Record<string, string>
: Declared<Path, Method> & Record<string, string>The declared header stays required and typed, and a request may carry others. The same shape applies
to query for an endpoint that declares one but should still accept more.
A field declared as never counts as declared, and means the field cannot be supplied: the fallback
is not reached and the option resolves to never. That is the intended reading for a hand-written
body: never, and a trap for a generator that emits an extractor call per field rather than deciding
whether one applies: queryType: QueryOf<typeof handler> on a handler that validates nothing
resolves to never, and the endpoint then has an option no value satisfies rather than an open one.
Omit the field instead, or, where the generator cannot tell, ask with never as the fallback and
treat that as undeclared, as above.
A field declared as unknown is the other case: not "cannot be supplied" but "cannot be typed". It is
never required, and the option accepts anything, so it is the right thing for a generator to emit
where its extractor gives up.
An extractor written as a conditional over a naked type parameter reaches never more often than its
author expects, since such a conditional distributes and short-circuits: one that cannot read a
handler yields never, which then reads here as "cannot be supplied" rather than as the fallback the
author had in mind. Guard the entry point with [T] extends [never], and prefer omitting a field to
emitting an extractor call that can fail.
A path that resolves to nothing, including one known only to be a string, falls back on every field
and requires none of them, so a client that accepts an opaque path still has usable options. The
response and error accessors differ deliberately: they resolve to never for such a path, since
there is no response to describe, and to unknown for a Request or a URL, which are accepted
inputs carrying no path to resolve.
Provides typed header access:
const contentType = response.headers.get('content-type') // string | null
const customHeader = response.headers.get('x-custom') // Typed based on schemaVariants that type header names but leave every value as string. Use them when the header map is
not known where the type is declared, such as when it comes from a generic parameter of your own
type:
interface MyRequest<HeaderMap extends Record<string, string>> {
headers: LooseTypedHeaders<HeaderMap>
}TypedHeaders resolves each value with a conditional lookup into the map, which TypeScript cannot
reduce while the map is still generic, so a plain Headers is neither assignable to nor from
TypedHeaders<HeaderMap> in that position.
Compile route sets into a module of types specialised to them. Each route is a list of segments,
already split; fetchdts does not parse route patterns.
import { compileRoutes } from 'fetchdts/compiler'
const { code, strategy, stats } = compileRoutes([
{ routes: serverRoutes },
{ routes: externalRoutes, origin: 'https://api.example.com' },
], { name: 'ServerRoutes', moduleSpecifier: 'nuxt/app' })imports on the result lists the names the emitted module imports, so a consumer re-exporting them
from its own entry can assert it covers them. An import the specifier does not provide resolves to
any rather than failing, because skipLibCheck suppresses the error in a declaration file, and
every type built on it then accepts anything.
The emitted module exports the route tree, an exact-match table where one is worth emitting, the path
union, and accessors specialised to the route set: ValidInput, Response, ResponseHeaders,
ErrorBody, RequestBody, RequestQuery, RequestHeaders and Requires. A consumer's signatures
then name only the emitted types and AnyFetchPath, so how a path is resolved is free to change
between versions of fetchdts without changing the signatures a consumer writes, since regenerating is
enough.
An artefact left behind by an upgrade fails to compile, because the emitted module imports the type names it uses. That holds only where the name changed, so an emitted-surface type is renamed whenever its meaning changes rather than quietly resolving to something else.
Where a consumer's route map can be extended by hand, pass resolveAgainst with the name of an
interface extending the emitted one:
// generated.ts, emitted with { name: 'GeneratedRoutes', resolveAgainst: 'ServerRoutes' }
// routes.ts
export interface ServerRoutes extends GeneratedRoutes {}The accessors then resolve against ServerRoutes, so a route someone adds by augmenting it is found,
while every path known at generation time still short-circuits through the exact-match table.
Without this the accessors are bound to the emitted interface, and an augmentation, which targets
the consumer's own interface, is invisible to them, so a hand-added route reads as a typo. Pass any
imports the emitted module needs for that name, or for types its metadata references, as imports.
Every strategy emits the same names. Which one is chosen depends on the route set: an exact-match
table is emitted for any static path, since a lookup replaces a walk, and the path union while the
paths reached through a parameter are few enough for it to stay cheap (unionLimit, 200 by default).
strategy in the result and a comment at the head of the module record what was chosen and the counts
that chose it; pass strategy to force one.
A type a route's handler cannot give up belongs in that route's metadata, not in an augmentation of the emitted module: an override reaches the table and the union, so it is offered as a completion, where a merged route reaches neither.
Method names are uppercased, as a router compares them case-insensitively, and a route with no
segments is emitted as '/'.
Route segments, method names and metadata fields come from whatever generated them, so every lookup
table built from them has a null prototype, a metadata value that is not a string is skipped, and an
interface name, a module specifier or a segment the compiler cannot emit is a TypeError rather than
invalid output. A static segment is also escaped where it lands inside an emitted template literal
type.
import { DynamicParam } from 'fetchdts'
import { compileRoutes } from 'fetchdts/compiler'
const { code } = compileRoutes([{ routes: [
{
segments: ['/users'],
metadata: {
GET: {
responseType: 'User[]'
},
POST: {
bodyType: '{ name: string }',
responseType: 'User'
}
}
},
{
segments: ['/users', DynamicParam, '/posts'],
metadata: {
GET: {
responseType: 'Post[]'
}
}
}
] }], { name: 'APISchema' })
console.log(code)
// Outputs the generated moduleA segment is either static or a parameter:
| Segment | Meaning |
|---|---|
'/users', 'users', { type: 'static', value: 'users' } |
a static segment |
'https://api.example.com' |
an origin, for cross-domain schemas |
DynamicParam, { type: 'dynamic' } |
exactly one segment |
WildcardParam, { type: 'wildcard' } |
the rest of the path, which may be empty; nothing may follow it |
A static segment spanning several path segments is split into one key per segment, and an origin is kept whole. A path is resolved by looking up a single segment as a key, so one key per segment is the shape that keeps resolution independent of how many routes a node holds; a key spanning several is still matched, by scanning the node's keys.
The object form exists so that tools which cannot pass symbols across a serialisation boundary can
still describe a route. Both forms produce identical output, and a static segment may itself contain
slashes ('/api/users'), which keeps the emitted tree shallower.
fetchdts does not parse route patterns. Every router spells a parameter differently, and the
schema only needs to know which segments are static, which match one segment, and which match the
rest, so the conversion belongs to whichever tool already owns your patterns.
If your patterns are rou3 patterns, routeNodeKeys gives the
canonical form directly, and gives it to you from the router that will serve the request, so the
generated types cannot drift from the routing:
import { DynamicParam, WildcardParam } from 'fetchdts'
import { compileRoutes } from 'fetchdts/compiler'
import { routeNodeKeys } from 'rou3'
const routes = endpoints.flatMap(({ pattern, method, responseType }) =>
// one pattern can land on several nodes: `/users/:id?` -> ['/users', '/users/*']
routeNodeKeys(pattern).map(key => ({
segments: key.split('/').slice(1).map(segment =>
segment === '*'
? DynamicParam
: segment === '**'
? WildcardParam
// escapes are preserved, so a literal `*` arrives as `\*`
: segment.replace(/\\(.)/g, '$1'),
),
// a handler with no method is valid for every verb
metadata: { [method ?? 'ALL']: { responseType } },
})),
)
const { code } = compileRoutes([{ routes }], { name: 'APISchema' })For filesystem routes, unrouting parses the major conventions
and converts between them, reporting each lossy step rather than silently widening a pattern.
Constrained parameters collapse onto one node: /users/:id(\d+) and /users/:slug([a-z]+) share a
single dynamic segment in the schema, so their response types are unioned. Radix-tree routers group
them the same way and re-check the constraint per node, which a type cannot do.
interface APISchema {
'/search': {
[Endpoint]: {
GET: {
query: { q: string, limit?: number }
headers: { 'x-api-key': string }
response: { results: string[], total: number }
responseHeaders: { 'x-rate-limit-remaining': string }
}
}
}
}
// Usage with required query and headers
const results = await api('/search', {
query: { q: 'typescript', limit: 10 },
headers: { 'x-api-key': 'your-key' }
})
// Access typed response headers
const rateLimit = results.headers.get('x-rate-limit-remaining') // string | nullinterface APISchema {
'/api': {
'/health': {
[Endpoint]: {
GET: { response: { status: 'ok' | 'error' } }
}
}
'/users': {
[Endpoint]: {
GET: { response: User[] }
POST: { body: CreateUser, response: User }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
PUT: { body: UpdateUser, response: User }
DELETE: { response: { deleted: boolean } }
}
'/posts': {
[Endpoint]: {
GET: { response: Post[] }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: Post }
}
}
}
}
}
}
}
// All of these are now typed:
await api('/api/health') // { status: 'ok' | 'error' }
await api('/api/users') // User[]
await api('/api/users/123') // User
await api('/api/users/123/posts') // Post[]
await api('/api/users/123/posts/456') // PostA handler that is valid for every HTTP verb can be expressed compactly with Record<HTTPMethod, ...>:
interface APISchema {
'/api': {
'/hello': {
[Endpoint]: Record<HTTPMethod, { response: { hello: string } }>
}
}
}compileRoutes emits that form for an ALL entry, which is worth using over spelling out every
method, as the generated file is several times smaller:
compileRoutes([{ routes: [
{
segments: ['/api', '/hello'],
metadata: {
ALL: { responseType: '{ hello: string }' },
// a specific method takes precedence over `ALL`
POST: { bodyType: '{ name: string }', responseType: 'Created' }
}
}
] }])A route whose response type isn't known, because a handler couldn't be resolved or is a proxy, is still a route, and should stay callable rather than looking like a typo. Register it for every method with nothing else declared:
compileRoutes([{ routes: [
{ segments: ['/api', '/proxy'], metadata: { ALL: {} } },
] }])
// [Endpoint]: Record<HTTPMethod, {}>The path is then a valid input, takes any method, and its response is unknown. A path that matches
nothing, or a method an endpoint doesn't register, is still never, so the two cases stay
distinguishable:
type A = TypedFetchResponseBody<APISchema, '/api/proxy'> // unknown
type B = TypedFetchResponseBody<APISchema, '/api/typo'> // neverOmitting metadata entirely is not the same thing: it emits no endpoint at all, so the path is not
offered. That is deliberate, so that a generator which skips handlers it cannot type doesn't widen
its surface by accident.
$fetch(`/api/posts/${id}`) has the type `/api/posts/${string}`, so it matches the dynamic
parameter, but at runtime id could be 'static' and hit a static sibling instead. A segment that
is not known in full reaches every branch it could take, so the response is the union of them:
interface APISchema {
'/api/posts': {
'/static': { [Endpoint]: { GET: { response: Static } } }
[DynamicParam]: { [Endpoint]: { GET: { response: Post } } }
}
}
type A = TypedFetchResponseBody<APISchema, `/api/posts/${string}`> // Post | Static
type B = TypedFetchResponseBody<APISchema, '/api/posts/123'> // Post
type C = TypedFetchResponseBody<APISchema, '/api/posts/static'> // StaticThis holds for a segment that is only partly known (`/api/posts/item-${string}` reaches
/api/posts/item-1 and the parameter, but no other sibling), and for endpoints below the segment, so
`/api/users/${string}/posts` accounts for a /api/users/me/posts sibling.
A static key spanning several segments ('/api/users' written as a single key) is matched by prefix
only, so a partly known segment does not reach it.
Where the union should be something other than what the schema implies, an endpoint can declare the response to use when the segment reaching it was not a literal, which takes precedence:
interface APISchema {
'/api/posts': {
'/static': { [Endpoint]: { GET: { response: Static } } }
[DynamicParam]: {
[Endpoint]: {
GET: {
response: Post
ambiguousResponse: Post | Static
}
}
}
}
}
type A = TypedFetchResponseBody<APISchema, `/api/posts/${string}`> // Post | Static
type B = TypedFetchResponseBody<APISchema, '/api/posts/123'> // Post
type C = TypedFetchResponseBody<APISchema, '/api/posts/static'> // StaticcompileRoutes carries an ambiguousResponseType like any other metadata field, so a generator that
already knows the union can declare it rather than have it derived. The declared response is used
only where a parameter consumed a non-literal segment; a wildcard is unaffected, since it matches
the rest of the path either way.
interface Schema {
'https://api.github.com': {
'/users': {
[DynamicParam]: {
[Endpoint]: {
GET: { response: GitHubUser }
}
'/repos': {
[Endpoint]: {
GET: { response: Repository[] }
}
}
}
}
}
}
// Works with full URLs
const user = await api('https://api.github.com/users/octocat')
const repos = await api('https://api.github.com/users/octocat/repos')interface APISchema {
'/api/users': {
[DynamicParam]: {
[Endpoint]: {
GET: {
response: User | { error: string, code: number }
}
}
}
}
}
const result = await api('/api/users/123')
// result is typed as: User | { error: string; code: number }
if ('error' in result) {
console.error(`Error ${result.code}: ${result.error}`)
}
else {
console.log(`User: ${result.name}`)
}Constrain the path by the union, keep the path parameter itself plain, and constrain the method by what that path answers:
declare function $fetch<T extends Paths, M extends AnyHTTPMethod = 'GET'>(
input: T,
init?: TypedFetchRequestInit<Schema, T, M> & { method?: M & TypedFetchMethods<Schema, T> },
): TypedFetchResponseBody<Schema, T, M>
declare function $fetch<T extends AnyFetchPath, M extends AnyHTTPMethod = 'GET'>(
input: T & ValidFetchInput<Schema, T, M>,
init?: TypedFetchRequestInit<Schema, T, M> & { method?: M },
): TypedFetchResponseBody<Schema, T, M>The first signature answers a path the union covers: the union gives an editor its completions, and
the plain parameter is what lets `/api/users/${id}` infer as a template literal type. The second
takes what the union cannot express (a query string, a fragment, a trailing slash, a Request or a
URL) and validates it.
The cost of the plain parameter is that a union member covering a route parameter is a pattern, so
'/api/users/1/2' satisfies `/api/users/${string}` and the first signature accepts it, with the
response resolving to never. Validate the first signature as well if that matters more than
inferring `/api/users/${id}`, since the two cannot both hold: intersecting the validator stops a
template literal argument from inferring, and the path then resolves as the whole union.
Three things have to be true at once and they pull against each other, so the shape is worth understanding rather than copying:
| wanted | needs |
|---|---|
completions from $fetch(' |
the union in the constraint of the first signature |
`/api/users/${id}` to resolve |
the path parameter not intersected with anything |
| a method the path does not answer to be rejected | the method constrained, since a path union carries no method |
Intersecting the validator onto the path parameter satisfies the third and breaks the second: a
template literal argument stops inferring as a template literal type and arrives as string, so the
call resolves to unknown or is rejected outright. Constraining the method with
TypedFetchMethods<Schema, T> closes the same hole without touching the path.
Note the method is constrained on the init member (method?: M & TypedFetchMethods<Schema, T>)
rather than on the type parameter. Constraining the parameter itself would make the = 'GET' default
illegal, and it is the same rule as never constraining a type parameter by a type computed from the
route map.
If your signature also lets a caller override the response type, widen the method's default when they
do. Naming any type argument turns inference off for the rest, so $fetch<Foo>(url, { method: 'post' })
would otherwise fall back to 'GET' and reject the method:
declare function $fetch<
R = void,
T extends Paths = Paths,
M extends AnyHTTPMethod = R extends void ? 'GET' : AnyHTTPMethod,
>(input: T, init?: { method?: M & TypedFetchMethods<Schema, T> }): R extends void ? TypedFetchResponseBody<Schema, T, M> : ROnce the caller has named the response, the method no longer decides what comes back, so widening the default costs nothing.
The init belongs in parameter position, and the method is worth passing to it as well as to the
response: it is otherwise resolved for every method the path answers, and describes only what they
have in common. Constrain the method parameter by AnyHTTPMethod rather than HTTPMethod, or
method: 'post' fails to infer and the call is rejected for a method the endpoint answers.
The union in the constraint is compared against the requested path at every call site, so that part of checking tracks the size of the schema; validation walks it instead, which costs the depth of one path. That is the whole of the difference between the three figures above.
Constraining a type parameter to the init type instead looks equivalent, but is not:
// avoid: `Init` is constrained by every path in the schema
declare function $fetch<T extends TypedFetchInput<Schema>, Init extends TypedFetchRequestInit<Schema, T>>(
input: T,
init?: Init,
): TypedFetchResponseBody<Schema, T, Init['method'] extends HTTPMethod ? Init['method'] : 'GET'>The constraint is instantiated with T as the whole path union, so the request init is computed for
every route in the schema before a single call site is checked. On a schema of any size this
dominates everything else: the cost stops tracking the number of call sites and starts tracking the
number of routes, several times over. pnpm bench --eager-init measures the difference.
Two smaller notes on the same signature. An endpoint declares the headers it requires, so add
{ headers?: Record<string, string> } to the init if callers should be free to send others.
And if some paths only accept a method other than GET, a second overload constrained to
TypedFetchInput<Schema, 'GET'> with an optional init keeps those paths from being called without
one.
Where the path arrives wrapped, as it does in a composable taking a ref or a getter, intersect the validator with the whole parameter rather than with the path inside it:
declare function useFetch<T extends AnyFetchPath, M extends AnyHTTPMethod = 'GET'>(
url: MaybeRefOrGetter<T> & ValidFetchInput<Schema, T, M>,
options?: UseFetchOptions<Schema, T, M> & TypedFetchRequestInit<Schema, T, M> & { method?: M },
): { data: Ref<TypedFetchResponseBody<Schema, T, M> | null> }TypeScript cannot infer through an intersection, so
MaybeRefOrGetter<T & ValidFetchInput<Schema, T, M>> leaves T at its constraint and every path
resolves to nothing. Intersected at the top level, T is still inferred from the ref or the getter,
and the failure still reports on the url argument.
Options that narrow the response, as transform and pick do, layer on top: resolve the response
once with TypedFetchResponseBody and let the further type parameters default from it, since the
path and method are already resolved by the time they are read.
For large APIs, consider organizing your schemas into modules:
// types/api.ts
interface UserAPI {
'/api/users': {
[Endpoint]: {
GET: { response: User[] }
POST: { body: CreateUser, response: User }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
PUT: { body: UpdateUser, response: User }
DELETE: { response: { success: boolean } }
}
}
}
}
interface PostAPI {
'/api/posts': {
[Endpoint]: {
GET: { query?: { limit?: number }, response: Post[] }
POST: { body: CreatePost, response: Post }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: Post }
PUT: { body: UpdatePost, response: Post }
DELETE: { response: { success: boolean } }
}
}
}
}
// Combine them
type APISchema = UserAPI & PostAPIWhile fetchdts provides compile-time type safety, consider adding runtime validation:
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email()
})
async function api<T extends TypedFetchInput<APISchema>>(
input: T,
init?: TypedFetchRequestInit<APISchema, T>
): Promise<TypedFetchResponseBody<APISchema, T>> {
const response = await fetch(input, init as RequestInit)
const data = await response.json()
// Runtime validation for critical endpoints
if (input.startsWith('/api/users/') && init?.method !== 'DELETE') {
return UserSchema.parse(data) // Throws if invalid
}
return data
}Design your schemas with error handling in mind:
interface APISchema {
'/api/users': {
[DynamicParam]: {
[Endpoint]: {
GET: {
response:
| { success: true, data: User }
| { success: false, error: string, code: number }
}
}
}
}
}
// Usage
const result = await api('/api/users/123')
if (result.success) {
console.log(result.data.name) // ✅ Type-safe access
}
else {
console.error(`Error ${result.code}: ${result.error}`)
}For the best experience, ensure your tsconfig.json includes:
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
}
}I would welcome contributions! Please see the Code of Conduct.
- Clone this repository
- Enable Corepack using
corepack enable - Install dependencies using
pnpm install - Run interactive tests using
pnpm devand type tests withpnpm test:types
Made with ❤️
Published under MIT License.