Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Build Type-Safe URLs from a Route Tree with jet-paths

Most projects start with a few URL strings. Before long, you have `/api/users/${id}` in one file, '/api/users/' + userId in another, and a route that still points to an endpoint you renamed months ago. jet-paths gives t

Most projects start with a few URL strings. Before long, you have `/api/users/${id}` in one file, '/api/users/' + userId in another, and a route that still points to an endpoint you renamed months ago.

jet-paths gives those URLs one place to live. You define a nested route tree, then call its routes like functions. TypeScript checks the parameters, and the library handles joining segments and encoding values.

Start with a route tree

Install the package:

npm install jet-paths

Then define your paths:

import jetPaths from 'jet-paths';

const Paths = jetPaths({
  $path: '/api',
  Users: {
    $path: '/users',
    Get: '/all',
    One: '/:id',
    Delete: '/delete/:id',
  },
});

Each route is callable, including the Users group:

Paths.Users();                // '/api/users'
Paths.Users.Get();            // '/api/users/all'
Paths.Users.One({ id: 5 });   // '/api/users/5'

You don't have to join /api, /users, and /:id yourself. And because One contains :id, TypeScript knows the call needs an id:

Paths.Users.One();            // Type error: missing argument
Paths.Users.One({ name: 5 }); // Type error: expected `id`

Sometimes you need the template rather than a finished path, such as when registering a server route. Both the local segment and the complete template are available:

Paths.Users.One.$path; // '/:id'
Paths.Users.One.$tmpl; // '/api/users/:id'

If you put the definition in a separate variable, use as const to preserve the literal route strings. Otherwise, TypeScript widens them to string and can't infer names such as id.

const definition = {
  $path: '/api',
  One: '/users/:id',
} as const;

const UserPaths = jetPaths(definition);
UserPaths.One({ id: 5 }); // '/api/users/5'

Add path and search parameters

Path parameters go in the first argument. You can pass search parameters in a second argument:

Paths.Users.Delete({ id: 5 });
// '/api/users/delete/5'

Paths.Users.Delete({ id: 5 }, { permanent: true });
// '/api/users/delete/5?permanent=true'

For a route without path parameters, the first argument is the search parameters:

Paths.Users.Get({ page: 2 });
// '/api/users/all?page=2'

You can pass arrays, too. They become repeated keys. undefined values are omitted, while false and 0 are kept:

Paths.Users.Get({
  tags: ['admin', 'editor'],
  page: 0,
  active: false,
  q: undefined,
});
// '/api/users/all?tags=admin&tags=editor&page=0&active=false'

Pass values as they are; jet-paths encodes them:

Paths.Users.Delete(
  { id: 'team/a' },
  { reason: 'duplicate entry' },
);
// '/api/users/delete/team%2Fa?reason=duplicate%20entry'

Search values must be primitives or arrays of primitives. For something like a Date, convert it to a string first.

Declare query parameters when you want stricter types

If a route accepts a known set of search parameters, you can put their names in the route definition. Add ! to make one required:

const UserPaths = jetPaths({
  $path: '/api',
  Users: {
    $path: '/users',
    Search: '/search?<q!><page><sort>',
    One: '/:id?<expand>',
  },
});

UserPaths.Users.Search({ q: 'sean', page: 2 });
// '/api/users/search?q=sean&page=2'

UserPaths.Users.One({ id: 5 }, { expand: true });
// '/api/users/5?expand=true'

Here, q is required; page and sort are optional. TypeScript also catches misspelled or undeclared keys:

UserPaths.Users.Search({ page: 2 });            // Type error: missing `q`
UserPaths.Users.Search({ q: 'sean', pgae: 2 }); // Type error: unknown key

The declarations describe the query parameters; they aren't included in the path template:

UserPaths.Users.Search.$tmpl; // '/api/users/search'

You can leave query parameters undeclared when a route needs to accept arbitrary keys. Declared keys are also checked at runtime, which helps when values come from JavaScript or escape TypeScript's checks.

Nest routes without repeating parameters

Groups can have path parameters of their own. Child routes inherit them:

const OrgPaths = jetPaths({
  $path: '/api',
  Org: {
    $path: '/orgs/:orgId',
    Members: '/members',
    Member: '/members/:memberId',
  },
});

OrgPaths.Org.Members({ orgId: 7 }, { page: 2 });
// '/api/orgs/7/members?page=2'

OrgPaths.Org.Member({ orgId: 7, memberId: 42 });
// '/api/orgs/7/members/42'

Members doesn't mention orgId in its own segment, but it still requires it because the parent route does.

You can also use $path: '' to organize routes in code without adding a URL segment:

const PublicPaths = jetPaths({
  $path: '/api',
  Public: {
    $path: '',
    Health: '/health',
    Status: '/status',
  },
});

PublicPaths.Public.Health(); // '/api/health'

Add an origin when you need one

The optional prepend setting adds a string to every generated path and complete template:

const Paths = jetPaths(
  {
    $path: '/api',
    Users: {
      $path: '/users',
      One: '/:id',
    },
  },
  { prepend: 'https://example.com' },
);

Paths.Users.One({ id: 5 });
// 'https://example.com/api/users/5'

prepend is added as written. Put dynamic parameters in the route tree, not in the prefix.

There's also a disableRegex option for skipping route-template validation. For example, it allows a static segment such as /@me, which the default validator rejects. Values passed to route functions are still encoded.

What happens with invalid values?

jet-paths validates route templates when you create the tree and checks values when you call a route. A path value can't be empty, . or .., because those values would change the structure of the URL:

Paths.Users.One({ id: '' }); // Throws at runtime

Path values must be primitives, and search values must be primitives or arrays of primitives. Those runtime checks are useful when callers use JavaScript or pass values whose types are broader than expected.

Using it in React

Create the route tree once at module level, then import it where you need it:

// paths.ts
import jetPaths from 'jet-paths';

export const Paths = jetPaths({
  $path: '/api',
  Users: {
    $path: '/users',
    One: '/:id',
  },
});

// UserLink.tsx
import { Paths } from './paths';

function UserLink({ id }: { id: number }) {
  return <a href={Paths.Users.One({ id })}>User {id}</a>;
}

That keeps route creation out of the component's render cycle. Calling an existing route to build a URL is cheap, so the link doesn't need special memoization.

Wrap-up

A route tree makes it easier to see which URLs your app uses and to change them in one place. With jet-paths, that definition also gives you checked path parameters, optional strict query parameters, encoding, and runtime validation.

If your project has URL strings scattered across components and API calls, you can start by moving a small group of routes into one tree and build from there.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.