tanstack-fetch 1.6.1: Type-Safe Path Params Without Losing Your DTO Types
One of the things I care about when building TypeScript APIs is not just whether the response is typed.
The request itself should be typed too.
Consider this:
await api.get<User>("/users/:id", {
params: {
id: userId,
},
});
At first glance, this looks type-safe.
But there is an important question:
How does TypeScript know that id actually exists in the URL?
And what happens if you accidentally write this?
await api.get<User>("/users/:id", {
params: {
userId: 123,
},
});
The response type is correct.
The request is not.
That is one of the problems I wanted to solve in tanstack-fetch 1.6.1.
What is tanstack-fetch?
tanstack-fetch is a typed Fetch client designed around the mental model of TanStack Query.
It is not an official TanStack package.
The idea is simple:
- Keep the native Fetch API model.
- Return data directly.
- Throw typed
FetchErrors for HTTP failures. - Preserve TanStack Query's
AbortSignal. - Support SSR and Edge runtimes.
- Keep advanced functionality such as SSE and interceptors composable.
The goal is not to replace TanStack Query.
Instead:
TanStack Query manages server state.
tanstack-fetch handles the HTTP transport.
The problem with path parameters
Imagine a typical API:
GET /users/:id
We want the following to be valid:
type User = {
id: string;
name: string;
};
type Params = {
id: number;
};
await api.get<User, Params>("/users/:id", {
params: {
id: 123,
},
});
But these should fail:
await api.get<User, Params>("/users/:id", {
params: {},
});
Because id is missing.
And:
await api.get<User, Params>("/users/:id", {
params: {
userId: 123,
},
});
Because the URL contains :id, not :userId.
And even this should fail:
await api.get<User, { userId: number }>("/users/:id", {
params: {
userId: 123,
},
});
The type and the URL don't agree.
That's exactly the kind of mistake TypeScript should catch before the request reaches your backend.
What's new in 1.6.1?
Version 1.6.1 introduces typed URL parameters directly from the path.
For example:
type User = {
id: string;
name: string;
};
type Params = {
id: number;
};
await api.get<User, Params>("/users/:id", {
params: {
id: 123,
},
});
Now the relationship between:
/users/:id
and:
{
id: number
}
is explicit.
If the parameter doesn't exist in the URL, TypeScript rejects it.
Response type + params type
You can keep both the response DTO and parameter map:
await api.get<User, Params>("/users/:id", {
params: {
id,
},
});
The first generic describes the response:
User
The second describes the path parameters:
Params
This gives you two independent layers of type safety:
URL
↓
params
↓
HTTP request
↓
response
↓
DTO
What about POST, PUT and PATCH?
The same idea also works for write operations.
For example:
type User = {
id: string;
name: string;
};
type Params = {
id: number;
};
type UpdateUser = {
name: string;
};
await api.put<User, Params, UpdateUser>("/users/:id", {
params: {
id,
},
body: {
name: "Ada",
},
});
Now we have three different types:
User
↓
Response
Params
↓
URL parameters
UpdateUser
↓
Request body
This makes the contract much more explicit.
Why not just use a route map?
An earlier version of tanstack-fetch experimented with a route map:
createFetch<Routes>()
Version 1.6.1 removes that approach.
Instead, the API keeps the route directly next to the request:
api.get<User, Params>("/users/:id", {
params: { id },
});
I prefer this approach because the type information stays close to the actual HTTP operation.
There is less global configuration and less ceremony.
A TypeScript limitation behind the API
There is an interesting TypeScript detail here.
TypeScript cannot partially infer generic parameters.
For example:
api.get<User>("/users/:id")
Once User is explicitly supplied, TypeScript can lose the literal information needed to validate the path parameters.
The practical result is that:
api.get<User, Params>("/users/:id", ...)
allows tanstack-fetch to preserve both:
- the response DTO
- the parameter map
That is why the API intentionally uses multiple type arguments.
What changed from 1.6.0?
The biggest change is actually a simplification.
1.6.0
The API experimented with:
createFetch<Routes>()
and a route map.
1.6.1
That approach is gone.
Instead:
api.get<User, { id: number }>("/users/:id", {
params: { id },
});
The route itself becomes the source of truth.
This makes the API easier to understand and reduces the amount of global type configuration.
Upgrade
If you're already using tanstack-fetch:
npm install tanstack-fetch@1.6.1
Or:
pnpm add tanstack-fetch@1.6.1
Why this matters
Type safety is often discussed in terms of response objects:
User
Product
Order
But an HTTP request has more than a response.
It has:
Method
URL
Path parameters
Query parameters
Request body
Headers
Response
Errors
If only the response is typed, a significant part of the API contract is still implicit.
With tanstack-fetch 1.6.1, the goal is to make another part of that contract explicit:
/users/:id
↓
{ id: number }
Small API details like this can prevent surprisingly common runtime bugs.
Final thoughts
tanstack-fetch started as a small typed wrapper around the Fetch API.
Over time, the focus has shifted toward something more specific:
making HTTP transport predictable, typed, and natural to use with TanStack Query.
Version 1.6.1 is another step in that direction.
Not a huge feature.
Not another abstraction layer.
Just better type safety where it matters.
If you're interested in the implementation and complete API examples:
- Documentation: https://mohamadgarmabi.github.io/tanstack-fetch/
- 1.6.1 release article: https://mohamadgarmabi.github.io/tanstack-fetch/blog/tanstack-fetch-1-6
- GitHub: https://github.com/mohamadgarmabi/tanstack-fetch
- npm: https://www.npmjs.com/package/tanstack-fetch
tanstack-fetch is an independent open-source project and is not an official TanStack package.

Top comments (0)