npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

typesafe-route

v1.0.0

Published

Type-safe URL builder and matcher for fetch() calls. Extracts :param names at compile time, resolves base URLs from Vite/Deno/Bun/Node/browser, and matches URLs back into typed params via URLPattern. Zero dependencies.

Downloads

290

Readme

typesafe-route

A tiny, type-safe URL builder and matcher powered by the URLPattern API. Resolves base URLs automatically from your environment so you never have to interpolate template strings into fetch() calls.

  • Zero dependencies — single file, ~800 lines
  • Type-safe path params — extracted from string literals at compile time
  • Runtime guards — throws on unreplaced params even when types are bypassed
  • Environment-aware — auto-detects base URL from Vite, Deno, Bun, Node, or browser
  • URLPattern matching — parse URLs back into typed params
  • TanStack Query friendly — just functions, no factories or classes

Install

# npm
npm install typesafe-route

# Deno / JSR
deno add jsr:@bastianplsfix/typesafe-route

URLPattern support: Native in Chromium, Node ≥ 23, Deno, and Bun. Firefox requires a polyfill. Note: only matchRoute needs URLPattern — route() works everywhere. If unavailable, matchRoute() throws a clear error.

Quick start

import { route } from "typesafe-route";

// In a TanStack Query hook
useSuspenseQuery({
  queryKey: ["bookmarks", id],
  queryFn: () => fetch(route("/api/bookmarks/:id", { path: { id } })).then((r) => r.json()),
});

The base URL is resolved automatically:

  1. import.meta.env.VITE_API_BASE / import.meta.env.API_BASE
  2. Deno.env.get("API_BASE")
  3. Bun.env.API_BASE
  4. process.env.API_BASE
  5. window.location.origin (browser runtime)
  6. http://localhost:3000 (fallback)

Use Cases

Client-side: Building API URLs

Perfect for: React, Vue, Svelte apps making fetch calls

import { route, createRoute } from "typesafe-route";

// TanStack Query
const userRoute = createRoute("/api/users/:id");

function useUser(id: string) {
  return useSuspenseQuery({
    queryKey: [userRoute.pattern, id],
    queryFn: () => fetch(userRoute({ path: { id } })).then((r) => r.json()),
  });
}

// Form submissions
async function updateUser(id: string, data: UserData) {
  await fetch(route("/api/users/:id", { path: { id } }), {
    method: "PUT",
    body: JSON.stringify(data),
  });
}

// Search/filtering with query params
const products = await fetch(
  route("/api/products", {
    search: { category: "shoes", size: ["9", "10"], sort: "price" },
  }),
).then((r) => r.json());
// → /api/products?category=shoes&size=9&size=10&sort=price

Server-side: Routing & URL Parsing

Perfect for: Deno/Bun HTTP servers, middleware, webhooks

import { matchRoute, tryMatchRoute, createRoute } from "typesafe-route";

// Define routes once
const userRoute = createRoute("/api/users/:id");
const productRoute = createRoute("/api/products/:id?");

// Server handler
Deno.serve((req) => {
  const url = req.url;

  // Match and extract params
  const userMatch = userRoute.match(url);
  if (userMatch) {
    return getUserById(userMatch.path.id);
  }

  const productMatch = productRoute.match(url);
  if (productMatch) {
    // Access query params too
    const { category, sort } = productMatch.search;
    return getProducts({
      id: productMatch.path.id,
      category,
      sort,
    });
  }

  return new Response("Not found", { status: 404 });
});

Advanced: Regex Patterns & Validation

Perfect for: Strict routing rules, API versioning, locale handling

import { matchRoute } from "typesafe-route";

// Only match numeric IDs
const userMatch = matchRoute("/api/users/:id(\\d+)", req.url);
if (!userMatch) {
  return new Response("Invalid user ID", { status: 400 });
}

// Locale routing
const blogMatch = matchRoute("/blog/:lang(en|no|de)/:slug", req.url);
if (blogMatch) {
  return renderBlogPost(blogMatch.path.lang, blogMatch.path.slug);
}

// File type validation
const fileMatch = matchRoute("/files/:filename.:ext(pdf|doc|txt)", req.url);

Testing & Debugging

Perfect for: Test assertions, debugging, admin panels

import { getBaseURL, getBaseInfo, getConfig, isURLPatternSupported, resetRouteConfig } from "typesafe-route";

// Test setup
beforeEach(() => {
  configureRoute({ base: "http://test-api.local" });
  expect(getBaseURL()).toBe("http://test-api.local");
});

// Conditional debugging
if (getBaseURL().includes("localhost")) {
  console.log("Dev mode - enabling mock data");
  enableMockMode();
}

// Admin panel
function ApiStatus() {
  const base = getBaseURL();
  const config = getConfig();

  return (
    <div>
      <p>API Endpoint: {base}</p>
      <p>Verbose: {config.verbose ? "ON" : "OFF"}</p>
    </div>
  );
}

API

route(pattern, options?)

Build a full URL from a pattern and params.

// Explicit path params
route("/api/bookmarks/:id", { path: { id: "42" } });
// → "http://localhost:3000/api/bookmarks/42"

// Explicit path + search
route("/api/bookmarks/:id", {
  path: { id: "42" },
  search: { fields: "title,url" },
});
// → "http://localhost:3000/api/bookmarks/42?fields=title%2Curl"

// Search only (no path params in pattern)
route("/api/bookmarks", { search: { page: "2", sort: "name" } });
// → "http://localhost:3000/api/bookmarks?page=2&sort=name"

// Array search params
route("/api/bookmarks", { search: { tag: ["a", "b"] } });
// → "http://localhost:3000/api/bookmarks?tag=a&tag=b"

// No params
route("/api/health");
// → "http://localhost:3000/api/health"

// Hash fragment
route("/docs/:section", {
  path: { section: "api" },
  hash: "route",
});
// → "http://localhost:3000/docs/api#route"

// Relative (pathname only, no base URL)
route("/api/bookmarks/:id", {
  path: { id: "42" },
  relative: true,
});
// → "/api/bookmarks/42"

// Per-call base URL override
route("/api/users/:id", {
  path: { id: "42" },
  base: "https://users.internal",
});
// → "https://users.internal/api/users/42"

// Optional param — omit or provide
route("/api/bookmarks/:id?", {}); // → ".../api/bookmarks"
route("/api/bookmarks/:id?", { path: { id: "42" } }); // → ".../api/bookmarks/42"

// Wildcard — zero-or-more segments (slashes preserved)
route("/files/:path*", { path: { path: "docs/readme.md" } }); // → ".../files/docs/readme.md"
route("/files/:path*"); // → ".../files"

// Wildcard — one-or-more segments (required)
route("/files/:path+", { path: { path: "docs/readme.md" } }); // → ".../files/docs/readme.md"

Option shape rule: Path params must be passed under path. Top-level keys are reserved for explicit options: path, search, hash, relative, and base.

matchRoute(pattern, url)

Match a URL against a pattern and extract params. Returns null on mismatch.

matchRoute("/api/bookmarks/:id", "http://localhost:3000/api/bookmarks/42");
// → { path: { id: "42" }, search: {} }

// Array search params are preserved
matchRoute("/api/bookmarks", "http://localhost:3000/api/bookmarks?tag=a&tag=b");
// → { path: {}, search: { tag: ["a", "b"] } }

Both route() and matchRoute() infer param names from the pattern literal:

const result = matchRoute("/api/:org/items/:id", url);
result?.path.org; // ✅ typed as string
result?.path.id; // ✅ typed as string
result?.path.foo; // ❌ type error

Advanced URLPattern syntax: matchRoute() supports the full URLPattern API, including regex constraints and custom patterns:

// Regex constraint - only digits
matchRoute("/api/:id(\\d+)", "http://localhost:3000/api/123");
// → { path: { id: "123" }, search: {} }

matchRoute("/api/:id(\\d+)", "http://localhost:3000/api/abc");
// → null (doesn't match)

// Enum pattern
matchRoute("/blog/:lang(en|no|de)/:slug", url);

// Named groups
matchRoute("/files/:filename.:ext", "http://localhost:3000/files/doc.pdf");
// → { path: { filename: "doc", ext: "pdf" }, search: {} }

Note: route() only supports basic syntax (:param, :param?, :param*, :param+) for type inference. For advanced patterns in route(), use type assertion: route("/api/:id(\\d+)" as any, { id: "123" } as any)

tryMatchRoute(pattern, url)

Non-throwing variant of matchRoute(). Returns null when the URL doesn't match or when URLPattern is unavailable.

const result = tryMatchRoute("/api/bookmarks/:id", maybeRelativeOrAbsoluteUrl);
if (!result) {
  // no match, or URLPattern unsupported
}

routePattern(pattern)

Bind a pattern for reuse. Returns a callable with .pattern and .match().

const bookmarks = routePattern("/api/bookmarks/:id");

// Use .pattern for query keys
useSuspenseQuery({
  queryKey: [bookmarks.pattern, id],
  queryFn: () => fetch(bookmarks({ path: { id } })).then((r) => r.json()),
});

// Match incoming URLs
bookmarks.match("http://localhost:3000/api/bookmarks/42");
// → { path: { id: "42" }, search: {} }

// Access the raw pattern
bookmarks.pattern;
// → "/api/bookmarks/:id"

configureRoute(config)

Optional one-time setup. Call at app startup.

configureRoute({
  base: "https://api.example.com", // explicit base (skips env detection)
  envKey: "BACKEND_URL", // custom env variable name
  fallback: "http://localhost:8080", // dev fallback
  trailingSlash: "strip", // "strip" | "preserve"
  verbose: true, // enable debug logging
});

Verbose logging:

By default, verbose logging is automatically enabled in development (when import.meta.env.DEV or NODE_ENV=development) and disabled in production.

// Auto-enabled in dev, off in prod (default behavior)
configureRoute({}); // or just don't call configureRoute at all

// Explicitly enable (even in production)
configureRoute({ verbose: true });

// Explicitly disable (even in dev)
configureRoute({ verbose: false });

// Granular control
configureRoute({
  verbose: {
    base: true, // Log base URL resolution (once)
    build: true, // Log each route() call
    match: false, // Don't log matchRoute() (can be very noisy)
  },
});

Example output (automatically shown in dev):

[typesafe-route] Base URL: http://localhost:3000 (source: fallback)
[typesafe-route] /api/users/:id → http://localhost:3000/api/users/42
[typesafe-route] /api/posts/:slug → http://localhost:3000/api/posts/hello-world

getBaseURL()

Get the current base URL being used by the library.

const base = getBaseURL();
console.log("API Base:", base); // "http://localhost:3000"

// Useful for conditional logic
if (getBaseURL().includes("localhost")) {
  console.log("Running in dev mode");
}

getBaseInfo()

Get both the resolved base URL and its source.

const info = getBaseInfo();
console.log(info.base); // "https://api.example.com"
console.log(info.source); // "config.base" | "env.API_BASE" | "window.location.origin" | "config.fallback" | "fallback"

Env-source testability: getBaseInfo().source reports env-derived values as "env.<KEY>" (for example "env.API_BASE"), which makes assertions straightforward in tests.

isURLPatternSupported()

Check whether URLPattern is available in the current runtime.

if (!isURLPatternSupported()) {
  // Install/polyfill URLPattern before using matchRoute()
}

resetRouteConfig()

Reset all route configuration and cached base-resolution state. Useful for tests or hot-reload flows.

configureRoute({ base: "https://api.example.com" });
resetRouteConfig();
// back to default resolution behavior

getConfig()

Get the current configuration (read-only copy).

const config = getConfig();
console.log("Verbose:", config.verbose);
console.log("Trailing slash:", config.trailingSlash);

Type safety

Param names are extracted from the pattern string literal at compile time:

route("/api/bookmarks/:id", { path: { id: "42" } }); // ✅
route("/api/bookmarks/:id", { path: { name: "oops" } }); // ❌ type error
route("/api/:org/bookmarks/:id", { path: { org: "acme" } }); // ❌ missing `id`
route("/api/bookmarks"); // ✅ no params required
route("/api/bookmarks/:id?"); // ✅ optional — args can be omitted
route("/api/:org/bookmarks/:id?", { path: { org: "acme" } }); // ✅ only required params needed

Optional and wildcard modifiers

Modifiers follow the URLPattern syntax:

| Modifier | Meaning | Type behavior | | -------- | ------------------------ | --------------------------- | | :id | Required, single segment | Required key | | :id? | Optional, single segment | Optional key | | :path* | Zero-or-more segments | Optional key, / preserved | | :path+ | One-or-more segments | Required key, / preserved |

When all params are optional (? or *), the options argument can be omitted entirely.

Why does my param need ? to be optional?

The pattern declares your URL's contract. If you have a value that might be undefined, you have three options:

const userId: string | undefined = session?.userId;

// ❌ Type error - pattern says :id is required, but userId might be undefined
route("/api/users/:id", { path: { id: userId } });

// ✅ Option 1: Make the pattern match reality
route("/api/users/:id?", { path: { id: userId } });

// ✅ Option 2: Guard it explicitly
if (userId) {
  route("/api/users/:id", { path: { id: userId } });
}

// ✅ Option 3: Provide a fallback
route("/api/users/:id", { path: { id: userId || "me" } });

This is intentional — the pattern syntax should match your data's optionality. It prevents bugs where you forget to handle missing params.

At runtime, if a :param survives replacement (e.g. the pattern was typed as string), route() throws:

Error: Unreplaced params in "/api/bookmarks/:id": :id. Received: {}

Encoding

Path params are always encoded via encodeURIComponent — pass raw values, not pre-encoded ones (e.g. "hello world" not "hello%20world"). matchRoute decodes them back, so round-trips are lossless. Search params are handled by URLSearchParams which encodes them natively.

Trailing slashes

Controlled via configureRoute({ trailingSlash }):

| Mode | /api/bookmarks/ | /api/bookmarks | | ------------ | ----------------- | ---------------- | | "strip" | /api/bookmarks | /api/bookmarks | | "preserve" | /api/bookmarks/ | /api/bookmarks |

Default is "preserve" (URLs are not modified).

Development

# Vite+ (npm)
vp test            # run test suite
vp pack            # build for npm

# Deno / JSR
deno publish       # publish to JSR

Exported types

import type {
  ParamValue, // string | number
  StripModifier, // strips ?, *, + suffixes from param names
  ExtractParams, // template literal type — extracts ":param" names
  RouteBuildExtras, // extra options (search, hash, relative, base)
  RouteOptions, // options union for route()
  MatchResult, // return type of matchRoute()
  BoundRoute, // return type of routePattern()
  RouteConfig, // config for configureRoute()
  BaseSource, // source literals for resolved base
  BaseInfo, // resolved base debug info
} from "typesafe-route";

License

MIT