Skip to content

Gamesome-ab/route-builder

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@gamesome/route-builder

Have you ever been frustrated by not having a good way to manage your application's routes in a type-safe manner? Are you resorting to magic strings scattered throughout your codebase, and constantly breaking prod when your api server tries to redirect to a non-existing route?

@gamesome/route-builder is here to help!

You can now build your application's routes in a type-safe way with support for dynamic segments like so:

import { buildRoutes } from '@gamesome/route-builder';

const routes = buildRoutes({
  $: '/',
    user: {
      $: '/users',
      id: (userId: string) => `/${userId}`,
    },
});

routes.$; // "/"
routes.user.$; // "/users"
routes.user.id('123'); // "/users/123"

You can also get fancy by creating a branded type like so:

type UserId = string & { __brand: 'UserId' };

const routes = buildRoutes({
  user: {
    id: (userId: string) => `/${userId as UserId}`,
  },
});

routes.user.id('123'); // "/users/${UserId}"

If you need base urls in your routes you can do that as well. You configure this in the second argument to buildRoutes by passing an object with a baseUrl property. If you want both relative and absolute urls you can set inSeparateBranch to true in the same object. Per default the base url will be represented as BaseUrl in typehints, but if you want the actual string you can set fullBaseUrlInTypeHints to true.

const routes = buildRoutes(
  {
	$: '/',
	about: {
	  $: '/about',
	},
  },
  {
	baseUrl: 'https://example.com',
	inSeparateBranch: true,
	fullBaseUrlInTypeHints: true,
  }
);

routes.$; // "/"
routes.about.$; // "/about"
routes.withBaseUrl.$; // "https://example.com/"
routes.withBaseUrl.about.$; // "https://example.com/about"

Type hints

In your IDE you will see autocompletion for both static and dynamic routes. as well as hints indicating what will be generated.

Preview of the entire route tree

dynamic route typehint

Preview of an entry

routes typehint

Custom base url

routes typehint

Shortened custom base url

routes typehint

Use cases

  • More ergonomic way of routing in your frontend application
  • Create a stable contract between your frontend and backend regarding frontend pages and their parameters
  • Organise your ts-rest api routes in a type-safe manner (if you for some reason don't use ts-rest yet, you really should check it out! Probably works in other setups as well though)

Installation

npm install @gamesome/route-builder
# or
yarn add @gamesome/route-builder

Publishing routes from a package

Most tsconfig.json setups work out of the box. If you export routes from a built package with declaration: true, TypeScript will emit fully typed .d.ts files — no extra tooling required. Just make sure your package.json exposes the right entry points (an exports map with types and import conditions, and/or top-level main + types).

See working examples in examples/turbo-with-built-packages.

Exception: isolatedDeclarations

When isolatedDeclarations: true is enabled, TypeScript requires every export to carry an explicit type annotation that the declaration emitter can resolve without running the type checker. Because buildRoutes returns a deeply inferred type, tsc will error on the bare export.

Why not just use as const or a helper function?

You might think you can sidestep the generator by extracting the route config into a variable with as const and annotating the export with a type like InferRoutes<typeof config>. This runs into two problems:

  1. as const doesn't narrow function return types. Arrow functions inside the config (dynamic route segments) still need individual explicit return type annotations, which quickly becomes verbose and error-prone for nested routes.
  2. A helper function like defineRoutes() can't be resolved in isolation. isolatedDeclarations requires that types are determinable without cross-file type inference. A function call's return type depends on the function's generic signature in another module, which the declaration emitter can't resolve.

Both approaches break down for any non-trivial route map that includes dynamic segments. The generator exists specifically to solve this — it pre-computes the fully resolved type and writes it to a .ts file that the declaration emitter can consume as-is.

For this case the library ships a generator CLI (route-builder-generate) and a companion function (buildRoutesWithGenerator):

import { buildRoutesWithGenerator } from '@gamesome/route-builder/generator';
import type { AppRoutes } from './routes.generated';

export const appRoutes: AppRoutes = buildRoutesWithGenerator({
  $: '/',
  users: {
    $: '/users',
    id: (userId: string) => `/${userId}`,
  },
});

Then generate the type file as part of your build:

route-builder-generate src/index.ts \
  --out src/routes.generated.ts \
  --export appRoutes \
  --type AppRoutes
  • src/index.ts — the source file that contains the route definition.
  • --out — where to write the generated type file.
  • --export — the name of the exported variable to read the type from (must match your export const …).
  • --type — the name of the generated type alias (must match the import type { … } in your source).

Use a .ts extension for --out (not .d.ts) so that tsc emits the corresponding .d.ts into dist/ automatically.

Setup, contributing and releasing

Setup

This project uses nix. Do direnv allow in the root directory after cloning to enable the nix shell automatically. This will ensure you have the correct Node.js version and other dependencies installed.

This project uses pnpm and nx as monorepo manager. To install dependencies, run (in the root folder):

pnpm install

The project has githooks set up to make sure code is formatted and tests are run before committing. This is mainly to keep the robots in check and avoid unnecessary CI runs.

Contributing

Feel free to open issues or submit pull requests! We welcome contributions of all kinds, whether it's bug fixes, new features, or documentation improvements.

Releasing

main is a protected branch, so releases are done via a release branch that gets squash-merged back. Each step is run separately to keep full control over what happens.

1. Create a release branch

git checkout -b release/next main

2. Bump the version

pnpm nx release version --no-git-commit --no-git-tag --no-git-push

Or edit packages/route-builder/package.json manually.

3. Generate the changelog

pnpm nx release changelog <version> --no-git-commit --no-git-tag --no-git-push

Replace <version> with the new version (e.g. 0.1.0). Review the generated changelog — if the commit messages don't follow conventional commits, you may need to write it by hand.

4. Commit, push, and open a PR

git add packages/route-builder/package.json packages/route-builder/CHANGELOG.md CHANGELOG.md
git commit -m "chore(release): <version>"
git push -u origin release/next
gh pr create --title "chore(release): <version>" --body "Bump @gamesome/route-builder to <version>"

5. Squash-merge the PR

Merge via GitHub (squash merge). This lands the version bump and changelog on main.

6. Tag and publish

After the merge, tag the squashed commit on main and publish:

git checkout main && git pull
git tag v<version>
git push --tags
pnpm nx release publish --otp=<your-npm-2fa-code>

Tag name should be something like v0.0.1

Cleaning up a failed release

If you need to delete a tag that was created prematurely:

git tag --delete <tag-name>
git push --delete origin <tag-name>

About

Typesafe, ergonomic javascript route-builder. No framework dependencies or magic string nonsense.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors