SkillAgentSearch skills...

Crosswalk

Typed express router for TypeScript

Install / Use

/learn @danvk/Crosswalk
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Crosswalk: safe routes for Express and TypeScript

codecov

This library helps you build type-safe REST APIs using Express using TypeScript.

Here's what you have to do:

  • Define your API using TypeScript types (see below).
  • Run ts-json-schema-generator to produce a JSON Schema for your API.

Here's what you get in return:

  • Type-safe API implementations (for your server)
  • Type-safe API requests (for your client code)
  • Runtime request validation (for your server, using [ajv][])
  • Interactive API documentation (via [swagger-ui-express][suie])

Requirements:

  • TypeScript 4.1+
  • Express

There is an optional requirement of [ts-json-schema-generator][tsjs] if you want runtime request validation or API docs. (You probably do!)

For a full example of a project using crosswalk, see this [demo repo][demo].

Usage

First install crosswalk and its peer dependencies (if you haven't already):

# if needed
npm install express
npm install -D typescript @types/express

npm install crosswalk

Then define your API in api.ts:

import type {Endpoint, GetEndpoint} from 'crosswalk/dist/api-spec';

export interface API {
  '/users': {
    get: GetEndpoint<UsersResponse, {query?: string}>;  // Response/query parameter types
    post: Endpoint<CreateUserRequest, User>;
  };
  '/users/:userId': {
    get: GetEndpoint<User>;
  }
}

Then implement the API (users.ts):

import {API} from './api';
import {TypedRouter} from 'crosswalk';

export function registerAPI(router: TypedRouter<API>) {
  router.get('/users', async ({}, req, res, {query}) => filterUsersByName(users, query));
  router.post('/users', async ({}, userInput) => createUser(userInput));
  router.get('/users/:userId', async ({userId}) => getUserById(userId));
}

Finally, register it on your Express server (server.ts):

const app = express();
app.use(bodyParser.json());
const typedRouter = new TypedRouter<API>(app);
registerAPI(typedRouter);
app.listen(4567);

There are a few things you get by doing this:

  • A definition of your API's shape in one place using TypeScript's type system.
  • A check that you've only implemented endpoints that are in the API definition.
  • Types for route parameters (via TypeScript 4.1's template literal types)
  • Types for query parameters (and automatic coercion of non-string parameters)
  • A check that each endpoint's implementation returns a Promise for the expected response type.

While not required, it's not much extra work to get runtime request validation and this is highly recommended. See "Runtime request validation" below.

For a complete example, check out the [crosswalk-demo repo][demo].

Type-safe API usage

In your client-side code, you can make type-safe API requests:

import {typedApi} from 'crosswalk';
import {API} from './api';

const api = typedApi<API>();
const getUserById = api.get('/users/:userId');
const createUser = api.post('/users');

async function demo () {
  const newUser = await createUser({}, {
    id: 'fred',
    name: 'Fred Flinstone'
  });  // Request body is type checked

  const user = await getUserById({userId: 'fred'});
  // Route parameters are type checked!
  // user's TypeScript type is User
  console.log(user.name);
}

This uses the fetch API under the hood, but you can plug in your own fetch function if you need to pass extra headers or prefer to use Axios.

You can also construct API URLs for mocking or requesting yourself. The path parameters will be type checked. No more /path/to/undefined/null!

import {apiUrlMaker} from 'crosswalk';
const urlMaker = apiUrlMaker<API>('/api/v0');
const getUserByIdUrl = urlMaker('/users/:userId');
const fredUrl = getUserByIdUrl({userId: 'fred'});
// /api/v0/users/fred
const userUrl = urlMaker('/users');
const fredSearchUrl = userUrl(null, {query: 'fred'});
// /api/v0/users?query=fred

Runtime request validation

To ensure that your users hit API endpoints with the correct payloads, use ts-json-schema-generator to convert your API definition to JSON Schema:

ts-json-schema-generator --no-ref-encode --no-top-ref --path api.ts API --out api.schema.json

Then pass this to the TypeRouter when you create it in server.ts:

const apiSchema = require('./api.schema.json');
const typedRouter = new TypedRouter<API>(app, apiSchema);

Now if the user hits an API endpoint with an incorrect payload, they'll get a friendly error message:

$ http POST :/user name="Fred"
{
    error: `data should have required property 'age'`,
}

You now have two representations of your API: api.ts and api.schema.json. The recommended way to keep them in sync is to run ts-json-schema-generator as part of your continuous integration workflow and fail if there are any diffs. The TypeScript definition (api.ts) is the source of truth, not the JSON Schema (api.schema.json).

Bells and Whistles

Error handling

You may throw an HTTPError in a handler to produce an error response. In users.ts:

import {API} from './api';
import {TypedRouter, HTTPError} from 'crosswalk';

function getUserById(userId: string): User | null {
  // ...
}

export function registerAPI(router: TypedRouter<API>) {
  router.get('/users/:userId', async ({userId}) => {
    const user = getUserById(userId);
    if (!user) {
      throw new HTTPError(404, `No such user ${userId}`);
    }
    return user;
  });
}

Verifying implementation completeness

With JSON Schema for your API (see above), the typed router can also check that you've implemented all the endpoints you declared. In server.ts:

const apiSchema = require('./api.schema.json');
const typedRouter = new TypedRouter<API>(app, apiSchema);
registerAPI(typedRouter);
typedRouter.assertAllRoutesRegistered();
// will throw unless all endpoints are registered

Route-aware middleware

Express middlware runs before you know which route is going to match. This means that you can't access route params, or the path that was matched. You can access these properties if you register a [finish][finish] handler, but by that point the request has been served and you can't do anything about it.

crosswalk lets you register middleware that runs after a route has been matched, but before the request has been handled. This is often a convenient place to apply access controls.

For example:

typedRouter.useRouterMiddleware((req, res, next) => {
  const {params} = req;
  if ('userId' in params && params.userId === 'badguy') {
    res.status(403).send('Forbidden');
  } else {
    next();
  }
});

You can also access req.route in this context. For example, req.route.path might be /users/:userId in the previous example.

Generating API docs

You can convert your API definition into Swagger form to get interactive HTML documentation.

First, install swagger-ui-express:

yarn add swagger-ui-express

Then convert your API schema to Open API format and serve it up:

import swaggerUI from 'swagger-ui-express';
import {TypedRouter, apiSpecToOpenApi} from 'crosswalk';

app.use('/docs', swaggerUI.serve, swaggerUI.setup(apiSpecToOpenApi(apiSchema)));

Then visit /docs. You may need to pass some additional options to apiSpecToOpenApi to get query execution from the Swagger docs to work.

Options

The TypedRouter class takes the following options.

invalidRequestHandler

By default, if request validation fails, crosswalk returns a 400 status code and a descriptive error. If you'd like to do something else, you may specify your own invalidRequestHandler. For example, you might like to log the error or omit validation details from the response in prod.

This is the default implementation (crosswalk.defaultInvalidRequestHandler):

new TypedRouter<API>(app, apiSchema, {
  handleInvalidRequest({response, payload, ajv, errors}) {
    response.status(400).json({
      error: ajv.errorsText(errors),
      errors,
      invalidRequest: payload,
    });
  }
});

Questions

GraphQL has types, why not use that?

If you want to use GraphQL, that's great! Go for it! But there are many reasons that REST APIs are still around. If you're already using REST and want to get type safety without havin to do a full GraphQL conversion, then Crosswalk can help.

Why do I have to define my API in TypeScript and JSON Schema?

[TypeScript types get erased at runtime][1], so if you want to use TypeScript types as your source of truth, you need to have some way of accessing them at runtime. For Crosswalk, that way is JSON Schema. This is a convenient form since there are many JSON Schema validators (such as [ajv][]) and JSON Schema is also used by OpenAPI, which makes it easy to generate documentation.

Why not use JSON Schema as the source of truth? Some developers choose to do this, but personally I find TypeScript's type declaration syntax much friendlier to use. And you'd still need some way to get TypeScript types out of it to get static type checking.

There are several tools for defining types that are available both at runtime and for TypeScript. If running ts-json-schema-generator bothers you, you might want to look into [iots][] or [zod][].

How do I keep api.ts and api.schema.json in sync?

I recommend adding a check to your continuous integration system that runs typescript-json-schema and then git diff to make sure there are no changes. You could also do this as a prepush or precommit hook.

How do I use middleware with this?

crosswalk is a thin wrapper around calling app.get, app.post, etc. Your middleware should work exactly as it did without crosswalk.

How do I register my API under a prefix?

Make a new router, wrap it with TypedRouter, and mount it wherever you like:

const app = express();
const rawApiRo

Related Skills

View on GitHub
GitHub Stars88
CategoryDevelopment
Updated2mo ago
Forks5

Languages

TypeScript

Security Score

80/100

Audited on Jan 21, 2026

No findings