DEV Community

Sean Maxwell
Sean Maxwell

Posted on

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

Then define your paths:

import jetPaths from 'jet-paths';

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

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

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

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

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

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

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

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

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

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

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

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

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

UserPaths.Users.Search.$tmpl; // '/api/users/search'
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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.

Top comments (0)