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

routype

v0.1.0

Published

Type-safe REST without codegen or framework lock-in.

Readme

🛤️ routype

Type-safe REST without codegen or framework lock-in.

For teams that already have REST endpoints and want a typed client without introducing RPC, OpenAPI, or a build step.

npm install routype
// before
const user = await fetch(`/users/${id}`).then(r => r.json()) // any

// after
const user = await api.getUser({ params: { id } }) // User

You write a typed description of your routes and reuse it on both sides. That's the whole idea.

import { t, defineRoute, createContract, createClient } from 'routype';

type User = { id: string; name: string; email: string };

const contract = createContract({
  getUser: defineRoute({
    method: 'GET',
    path: '/users/:id',
    params: t<{ id: string }>(),
    output: t<User>(),
  }),

  createUser: defineRoute({
    method: 'POST',
    path: '/users',
    body: t<{ name: string; email: string }>(),
    output: t<User>(),
  }),
});

const api = createClient(contract, { baseUrl: 'https://api.example.com' });

const user = await api.getUser({ params: { id: '123' } });
//    ^? User

const created = await api.createUser({ body: { name: 'Alice', email: '[email protected]' } });
//    ^? User

Params, query, body, and output are all inferred. Wrong shapes are compile errors.

Why

Tools like tRPC and OpenAPI solve typed API communication by introducing new layers - RPC abstractions, schema files, codegen pipelines. Both work well, but both require buying into more architecture than the problem demands.

routype keeps your existing REST endpoints and adds types on top. You describe your routes with a lightweight helper. TypeScript infers the rest.

Bring your own server, bring your own validator. We only connect the types.

| | routype | tRPC | OpenAPI | |---|---|---|---| | Typed client | ✅ | ✅ | ✅ | | Requires new architecture | ❌ | ✅ | ❌ | | Code generation | ❌ | ❌ | ✅ | | Runtime dependency | ❌ | ✅ | ❌ | | Works with existing REST | ✅ | ⚠️ | ✅ |

No magic

routype does not:

  • generate files or clients
  • inspect your server at runtime
  • require shared runtime code between client and server
  • change how your requests are handled

It's a thin typed wrapper over fetch. The core is ~200 lines.

Quickstart

1. Define routes

// contract.ts
import { t, defineRoute, createContract } from 'routype';

type User = { id: string; name: string; email: string };

export const contract = createContract({
  getUser: defineRoute({
    method: 'GET',
    path: '/users/:id',
    params: t<{ id: string }>(),
    output: t<User>(),
  }),

  listUsers: defineRoute({
    method: 'GET',
    path: '/users',
    query: t<{ search?: string; limit?: number }>(),
    output: t<User[]>(),
  }),

  createUser: defineRoute({
    method: 'POST',
    path: '/users',
    body: t<{ name: string; email: string }>(),
    output: t<User>(),
  }),

  deleteUser: defineRoute({
    method: 'DELETE',
    path: '/users/:id',
    params: t<{ id: string }>(),
    output: t<void>(),
  }),
});

2. Create a client

// api.ts
import { createClient } from 'routype';
import { contract } from './contract';

export const api = createClient(contract, {
  baseUrl: 'https://api.example.com',
  headers: () => ({ Authorization: `Bearer ${getToken()}` }),
});

3. Call it

const user = await api.getUser({ params: { id: '123' } });
//    ^? User

const users = await api.listUsers({ query: { search: 'alice', limit: 10 } });
//    ^? User[]

const created = await api.createUser({ body: { name: 'Bob', email: '[email protected]' } });
//    ^? User

// routes with no input take no arguments
const health = await api.healthCheck();

API

t<T>()

Type helper. Tells TypeScript what shape to expect. Returns undefined at runtime - zero cost.

params: t<{ id: string }>()
output: t<User>()

defineRoute(config)

defineRoute({
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS',
  path: '/users/:id',
  params?: t<Params>(),     // path params - stringified and URL-encoded
  query?: t<Query>(),       // query string - primitives and arrays of primitives
  body?: t<Body>(),         // request body - forbidden on GET/HEAD at the type level
  output: t<Output>(),      // response type
})

Only method, path, and output are required. Omit params/query/body and the client won't ask for them.

createContract(routes)

Identity function that preserves literal types. Exists for ergonomics and grouping.

const contract = createContract({ getUser, listUsers, createUser });

createClient(contract, options)

const client = createClient(contract, {
  baseUrl: string,
  fetch?: (url: string, init?: RequestInit) => Promise<Response>,
  headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>),
  mapResponse?: <T>(res: Response) => Promise<T>,
  parseError?: (res: Response) => Promise<unknown>,
});

mapResponse - transform successful responses. Default parses JSON when content-type includes application/json or +json, returns text for other content types, undefined for 204 or missing content-type.

// unwrap an envelope API
createClient(contract, {
  baseUrl: '/api',
  mapResponse: async <T>(res: Response) => (await res.json() as { data: T }).data,
});

parseError - parse error response bodies before attaching to HttpError. Default tries JSON, falls back to text.

headers - static object or async function. Merged into every request. Won't overwrite a Content-Type you set explicitly.

Serialization:

  • params - replaces :token segments, stringifies and URL-encodes values
  • query - URLSearchParams, repeats keys for arrays, omits null/undefined
  • body - JSON.stringify by default, passes through FormData/Blob/ReadableStream as-is

HttpError

Thrown on non-2xx responses. Carries the status code and parsed body.

import { HttpError } from 'routype';

try {
  await api.getUser({ params: { id: '999' } });
} catch (e) {
  if (e instanceof HttpError) {
    e.status // 404
    e.body   // parsed JSON or raw text
  }
}

Zod integration

Optional. Import from routype/zod to derive types from Zod schemas. One source of truth for both runtime validation and TypeScript types.

import { zRoute } from 'routype/zod';
import { z } from 'zod';
import { createContract, createClient } from 'routype';

const getUser = zRoute({
  method: 'GET',
  path: '/users/:id',
  params: z.object({ id: z.string() }),
  output: z.object({ id: z.string(), name: z.string() }),
});

const contract = createContract({ getUser });

// server - validate at runtime
app.get('/users/:id', async (req, res) => {
  const { id } = getUser.schemas.params.parse(req.params);
  const user = await db.users.findById(id);
  res.json(user);
});

// client - fully typed
const api = createClient(contract, { baseUrl: '/api' });
const user = await api.getUser({ params: { id: '1' } });
//    ^? { id: string; name: string }

Framework examples

routype doesn't touch your server. Use the contract types however fits your stack.

Express

import { contract } from './contract';

app.get('/users/:id', async (req, res) => {
  const { id } = req.params as typeof contract.getUser._params;
  const user = await db.users.findById(id);
  res.json(user satisfies typeof contract.getUser._output);
});

Hono

import { contract } from './contract';

app.get('/users/:id', async (c) => {
  const { id } = c.req.param() as typeof contract.getUser._params;
  const user = await db.users.findById(id);
  return c.json(user satisfies typeof contract.getUser._output);
});

Next.js route handlers

import { contract } from './contract';

export async function GET(req: Request, { params }: { params: typeof contract.getUser._params }) {
  const user = await db.users.findById(params.id);
  return Response.json(user satisfies typeof contract.getUser._output);
}

Status

Early, but stable. The API surface is intentionally small and expected to remain mostly additive.

Design decisions

  • Zero dependencies. Zod integration is a separate entrypoint.
  • No codegen, no schemas required, no build step, no decorators.
  • Works with any framework that speaks HTTP.
  • Types are compile-time only. Runtime is just fetch with path interpolation and query serialization.
  • body is forbidden on GET/HEAD at the type level.
  • Query values are constrained to primitives and arrays of primitives - matching what URLSearchParams can actually serialize.