1.2.1 • Published 23 days ago

react-router-typesafe-routes v1.2.1

Weekly downloads
-
License
MIT
Repository
github
Last release
23 days ago

React-Router-Typesafe-Routes

Comprehensive type-safe routes for react-router.

npm

The library provides extensible type safety for path params, query params, and hash on building and parsing URLs.

There is also some support for route state typing.

Installation

yarn add react-router-typesafe-routes

Note that the library is using ES6, including ES6 modules. It's designed to be processed by some bundler like Webpack.

Design principles

  • Mess with react-router API as little as possible.
  • No unsafe type casts.
  • Extensibility to allow better typing and/or validation.
  • Completeness: cover every aspect of the URL.

How is it different from existing solutions?

Quick usage example

Route definition may look like this:

import { route, path, query, hash, param } from "react-router-typesafe-routes";

const routes = {
    PRODUCT: route(path("/product/:id"), query({ age: param.number.optional }, hash("about", "more"))),
};

Use Route components as usual:

import { Route } from "react-router";
import { routes } from "./path/to/routes";

<Route path={routes.PRODUCT.path} />;

Use Link components as usual:

import { Link } from "react-router-dom";
import { routes } from "./path/to/routes";

// Everything is fully typed!
<Link to={routes.PRODUCT.build({ id: 1 }, { age: 12 }, "about")} />;

Parse params with usual hooks:

import { useParams, useLocation } from "react-router";
import { routes } from "./path/to/routes";

// You can use useRouteMatch() instead of useParams()
const { path, query, hash } = routes.PRODUCT.parse(useParams(), useLocation());

You can also parse only what you need:

import { useParams, useLocation } from "react-router";
import { routes } from "./path/to/routes";

// Again, you can also use useRouteMatch()
const path = routes.PRODUCT.parsePath(useParams());
const query = routes.PRODUCT.parseQuery(useLocation());
const hash = routes.PRODUCT.parseHash(useLocation());

In-depth explanation

route

A route is defined via the route helper, which accepts processors of different URL parts. These processors are created via the path, query, and hash helpers. The path processor is required, and the other processors are optional.

If some processor is omitted, the corresponding part of the URL won't be processed at all. Pass null or undefined to omit the query processor and specify a hash processor.

const pathRoute = route(path("/path"));
const queryRoute = route(path("/path"), query());
const hashRoute = route(path("/path"), null, hash());
const fullRoute = route(path("/path"), query(), hash());

The route helper returns an object with path property, containing the original URL path, and build and parse functions. There are also buildPath, buildQuery, buildHash, parsePath, parseQuery, and parseHash functions provided for convenience.

route.build

The build function accepts three arguments that are params for corresponding processors and returns the URL string with the given parameters. Only the first argument (params for the path processor) is required, even if it's just an empty object.

Pass null or undefined to skip query building and specify hash.

const pathUrl = fullRoute.build({});
const queryUrl = fullRoute.build({}, { queryParam: "foo" });
const hashUrl = fullRoute.build({}, null, "bar");

Remember that you can also build individual URL parts via the buildPath, buildQuery, and buildHash functions.

route.parse

The parse function accepts two arguments that are match (including null) or match.params and location objects from react-router and returns an object with path, query, and hash fields, containing the corresponding parameters from the URL. Only the first argument is required. The query and hash fields will be undefined if no location or corresponding processors were provided.

const fullParams = fullRoute.parse(useParams(), useLocation());
const pathParams = fullRoute.parse(useParams());

Remember that you can also parse individual URL parts via the parsePath, parseQuery, and parseHash functions.

param

The param helper is used to define types for path and query params. It provides a set of transformers, which transform values to something that can be stored in a URL (usually string | undefined) and back. If the reverse transformation is impossible, it throws an error. This usually means that the value in the URL was changed unexpectedly.

The predefined transformers are:

  • param.string - single string;
  • param.number - single number;
  • param.boolean - single boolean;
  • param.null - single null;
  • param.date - single Date (stored in the URL as an ISO string);
  • param.oneOf() - one of the given single values, for instance param.oneOf(1, 'foo', true);
  • param.arrayOf() - array of the given transformer, for instance param.arrayOf(param.number).

All the above transformers also have an .optional modifier, which means that the corresponding value may be undefined. If the reverse transformation of such a value fails, undefined is returned instead. You can also call it to specify the fallback value like this: param.number.optional(1).

You can write custom transformers using the existing ones as an example.

Caveats

  • URL processors determine which transformers can be used in them.

  • For array fields, the reverse transformation of undefined will result in an empty array.

  • Avoid specifying values that have the same string representation in param.oneOf(). For instance, in param.oneOf(1, '1') the '1' value will never be reached.

  • Fields marked as param.string will also accept number and boolean values on build, since they are trivially convertible to string. On parse, though, such fields will always be string. If that's not what you want, you can write a custom strictString, using the param.string as an example.

path

A path processor is created via the path helper. In a simple scenario, you can just pass a URL path to it and have some typing out of the box. That's exactly how generatePath from react-router works.

const myRoute = route(path("/test/:foo/:bar?"));

// { foo: string | number | boolean; bar?: string | number | boolean }
const url = myRoute.build({ foo: 1 });

// { foo: string; bar?: string }
const pathParams = myRoute.parsePath(useParams());

In a lot of cases, you can get away with it. However, at the time of writing, it breaks on complex scenarios like /test/:foo(\\d+)?. It likely will improve, but what if we want to fix it right now? What if we want more precise typing on parsed params?

In that case, we can completely override the inferred type with our own. Note that it's your responsibility to sync this type with the actual path string.

You can use transformers that store values as string | string[] | undefined.

const myRoute = route(path("/test/:foo/:bar(\\d+)?", { foo: param.string, bar: param.number.optional }));

// { foo: string | number | boolean; bar?: number }
const url = myRoute.build({ foo: 1 });

// { foo: string; bar?: number }
const pathParams = myRoute.parsePath(useParams());

Parsing details

We're parsing the match or match.params object from react-router to get valid params of the given route.

If we didn't specify a custom type, and we're parsing the match object, we simply compare the match.path with the path of the given route. If they match, the match.params are considered valid. If we're parsing the match.params, we check that they have all the required fields of the path of the given route. If they do, the match.params are considered valid.

If we specified a custom type, we simply try to transform the given match.params. If we could transform every parameter, the transformed match.params are considered valid. Additionally, if we're parsing the match object, we check the match.path field as well.

If we couldn't get valid params, an error will be thrown. It means that we are processing an unexpected route, or there is a mismatch between the URL path and the custom type.

Caveats

  • If you're using param.arrayOf (* or + modifiers), you should specify the path: true option like this: param.arrayOf(param.number, { path: true }). It allows transforming strings like 'foo/bar' into arrays like ['foo', 'bar']. Otherwise, you'll get something like ['foo/bar'].

  • Path params are not decoded, but you most likely don't want them to be encoded in the first place.

query

A query processor is created via the query helper. It's built upon query-string, and you can use its options.

If you want, you can create a processor without custom type and have the query-string types with a minor improvement: the ability to store null values inside arrays is now checked and depends on the arrayFormat option.

const myRoute = route(path("/test"), query());
const myCommaRoute = route(path("/test"), query(null, { arrayFormat: "comma", parseNumbers: true }));

// Record<string, any> due to https://github.com/sindresorhus/query-string/issues/298
const url = myRoute.build({}, { foo: "foo" });
const commaUrl = myCommaRoute.build({}, { foo: "foo" });

// Record<string, string | null | (string | null)[]>
const queryParams = myRoute.parseQuery(useLocation());
// Record<string, string | number | null | (string | number)[]>
const commaQueryParams = myCommaRoute.parseQuery(useLocation());

You can make types more specific by providing a custom type. Note that, in this case, you can't set the parseNumbers and parseBooleans options to true, because the parsing is now done by the specified transformers.

You can use transformers that store values as string | null | (string | null)[] | undefined. Again, the ability to store nulls inside arrays depends on the arrayFormat option.

The transformers must be able to both accept and store the value as undefined. For predefined transformers, it means that only the .optional variants can be used. This is because query params are always optional by their nature.

const myRoute = route(path("/test"), query({ foo: param.number.optional }));
const myCommaRoute = route(path("/test"), query({ foo: param.string.optional("foo") }, { arrayFormat: "comma" }));

// { foo?: number }
const url = myRoute.build({}, { foo: 1 });
// { foo?: string | number | boolean }
const commaUrl = myCommaRoute.build({}, { foo: "foo" });

// { foo?: number }
const queryParams = myRoute.parseQuery(useLocation());
// { foo: string }
const commaQueryParams = myCommaRoute.parseQuery(useLocation());

On parse, if some value can't be transformed, an error will be thrown. It shouldn't normally happen, because the transformers have to both accept and store the value as undefined, and such predefined transformers never throw.

Caveats

  • If you are writing a custom transformer for query, make sure that it doesn't throw. This way, you would be able to parse any query safely. The easiest way to achieve that is to use the optional helper as the built-in transformers do.

  • query-string technically always lets you store nulls inside arrays, but they get converted to empty strings with certain array formats. It's quite tedious to type, and I doubt that anyone needs this.

  • query-string lets you stringify arrays with undefined values, omitting them. If you need this behavior, you can write a custom transformer.

hash

A hash processor is created via the hash helper. It's the simplest of all. You can call it without parameters:

const myRoute = route(path("/test"), null, hash());

// string
const url = myRoute.build({}, null, "foo");

// string
const hashValue = myRoute.parseHash(useLocation());

Or you can specify the allowed values:

const myRoute = route(path("/test"), null, hash("foo", "bar"));

// 'foo' | 'bar'
const url = myRoute.build({}, null, "foo");

// '' | 'foo' | 'bar'
const hashValue = myRoute.parseHash(useLocation());

On parse, if the hash has an unexpected value, an empty string is returned instead.

What about route state?

In fact, you can pass a state processor as the fourth argument to route:

import { route, path } from "react-router-typesafe-routes";
import { state } from "./path/to/state";

const myRoute = route(path("/test/:id"), null, null, state);

You can build a location object via the route.buildLocation function:

import { Link } from "react-router-dom";
import { myRoute } from "./path/to/my-route";

<Link to={myRoute.buildLocation({ stateField: "foo" }, { id: 1 })} />;

You can also use the route.buildState and route.parseState functions to transform a state object to the serializable form and back. The parsed state object will also appear in the result of the route.parse execution.

The only catch is... there is no implementation of the state processor 😅. It's the most tricky processor to implement in a generic form, and also the least used one.

In the meantime, you can write ad-hoc state processors and get type safety for route state where you need it, but that's about it.

1.2.2

23 days ago

1.2.1

11 months ago

1.2.0

1 year ago

1.0.0

1 year ago

1.2.0-dev.4

1 year ago

1.2.0-dev.3

1 year ago

1.2.0-dev.5

1 year ago

1.2.0-dev.0

1 year ago

1.2.0-dev.2

1 year ago

1.2.0-dev.1

1 year ago

1.1.0

1 year ago

0.5.1

1 year ago

0.5.0

2 years ago

0.4.3

2 years ago

0.4.2

2 years ago

0.4.0-alpha.18

2 years ago

0.4.0-alpha.4

2 years ago

0.4.0-alpha.19

2 years ago

0.4.0-alpha.3

2 years ago

0.4.0-alpha.2

2 years ago

0.4.0-alpha.1

2 years ago

0.4.0-alpha.14

2 years ago

0.4.0-alpha.15

2 years ago

0.4.0-alpha.16

2 years ago

0.4.0-alpha.17

2 years ago

0.4.0-alpha.10

2 years ago

0.4.0-alpha.11

2 years ago

0.4.0-alpha.12

2 years ago

0.4.0-alpha.13

2 years ago

0.4.0-alpha.9

2 years ago

0.4.0-alpha.8

2 years ago

0.4.0-alpha.7

2 years ago

0.4.0-alpha.6

2 years ago

0.4.0-alpha.5

2 years ago

0.4.0-alpha.21

2 years ago

0.4.0-alpha.22

2 years ago

0.4.0-alpha.23

2 years ago

0.4.0-alpha.20

2 years ago

0.4.1

2 years ago

0.3.2

2 years ago

0.4.0

2 years ago

0.3.1

3 years ago

0.3.0

3 years ago

0.2.4

3 years ago

0.2.2

3 years ago

0.2.1

3 years ago

0.2.0

3 years ago

0.1.1

3 years ago

0.1.0

3 years ago