DEV Community

mohammad garmabi
mohammad garmabi

Posted on

tanstack-fetch 1.6.1: Type-Safe Path Params Without Losing Your DTO Types

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

But these should fail:

await api.get<User, Params>("/users/:id", {
  params: {},
});
Enter fullscreen mode Exit fullscreen mode

Because id is missing.

And:

await api.get<User, Params>("/users/:id", {
  params: {
    userId: 123,
  },
});
Enter fullscreen mode Exit fullscreen mode

Because the URL contains :id, not :userId.

And even this should fail:

await api.get<User, { userId: number }>("/users/:id", {
  params: {
    userId: 123,
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

Now the relationship between:

/users/:id
Enter fullscreen mode Exit fullscreen mode

and:

{
  id: number
}
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

The first generic describes the response:

User
Enter fullscreen mode Exit fullscreen mode

The second describes the path parameters:

Params
Enter fullscreen mode Exit fullscreen mode

This gives you two independent layers of type safety:

URL
 ↓
params
 ↓
HTTP request
 ↓
response
 ↓
DTO
Enter fullscreen mode Exit fullscreen mode

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

Now we have three different types:

User
  ↓
Response

Params
  ↓
URL parameters

UpdateUser
  ↓
Request body
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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", ...)
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

and a route map.

1.6.1

That approach is gone.

Instead:

api.get<User, { id: number }>("/users/:id", {
  params: { id },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or:

pnpm add tanstack-fetch@1.6.1
Enter fullscreen mode Exit fullscreen mode

Why this matters

Type safety is often discussed in terms of response objects:

User
Product
Order
Enter fullscreen mode Exit fullscreen mode

But an HTTP request has more than a response.

It has:

Method
URL
Path parameters
Query parameters
Request body
Headers
Response
Errors
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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:

tanstack-fetch is an independent open-source project and is not an official TanStack package.

Top comments (0)