Giter Site home page Giter Site logo

fenok / react-router-typesafe-routes Goto Github PK

View Code? Open in Web Editor NEW
120.0 3.0 3.0 157.14 MB

Comprehensive and extensible type-safe routes for React Router v6 with first-class support for nested routes and param validation.

License: MIT License

JavaScript 1.17% TypeScript 98.83%
react-router type-safe react typescript validation

react-router-typesafe-routes's Introduction

React Router Typesafe Routes 🍣

Comprehensive and extensible type-safe routes for React Router v6 with first-class support for nested routes and param validation.

npm

The library provides type safety for all route params (path params, search params (including multiple keys), state, and hash) on building and parsing URL parts and state objects. There are no unsafe type casts whatsoever.

If you want, you can use a validation library. There is first-party support for Zod and Yup, and other libraries are easily integratable. If not, you can use other built-in types and fine-tune their validation instead.

In built-in types, parsing and validation errors are caught and replaced with undefined. You can also return a default value or throw an error in case of an absent or invalid param. All these adjustments reflect in types, too!

If you need more control, you can build completely custom types, which means that parsing, serializing, and typing are fully customizable.

The library doesn't restrict or alter React Router API in any way, including nested routes and relative links. It's also gradually adoptable.

⚠ Migrating to universal types? Check out this small guide.

Installation

yarn add react-router-typesafe-routes

You'll need to use one of platform-specific entry points, each of which requires react as a peer dependency:

  • react-router-typesafe-routes/dom for web, react-router-dom is a peer dependency;
  • react-router-typesafe-routes/native for React Native, react-router-native is a peer dependency.

Additionally, there are optional entry points for types based on third-party validation libraries:

  • react-router-typesafe-routes/zod exports zod type, zod is a peer dependency;
  • react-router-typesafe-routes/yup exports yup type, yup is a peer dependency;

The library is targeting ES6 (ES2015). ESM is used by default, and CommonJS is only usable in environments that support the exports field in package.json.

Limitations & Caveats

  • To make params merging possible, the state has to be an object, and the hash has to be one of the known strings (or any string).
  • Since React Router only considers pathname on route matching, search parameters, state fields, and hash are considered optional upon URL or state building.
  • For simplicity, the hash is always considered optional upon URL parsing.
  • For convenience, absent and invalid params are considered virtually the same by built-in types (but you have full control with custom types).
  • To prevent overlapping with route API, child routes have to start with an uppercase letter (this only affects code and not the resulting URL).
  • To emphasize that route relativity is governed by the library, leading slashes in path patterns are forbidden. Trailing slashes are also forbidden due to being purely cosmetic.

How is it different from existing solutions?

Feature react-router-typesafe-routes typesafe-routes typed-react-router
Type-safe path params βœ… βœ… βœ…
Type-safe search params βœ… βœ… 🚫
Multiple identical keys in search params βœ… 🚫️ 🚫
Type-safe state βœ… 🚫 🚫
Type-safe hash βœ… 🚫 🚫
Customizable serialization βœ… βœ… 🚫
Customizable parsing / validation βœ… βœ… 🚫
Built-in types allow to customize validation and absent / invalid param handling βœ… 🚫 🚫
Nested routes βœ… βœ… βœ…
Relative links βœ… βœ… 🚫
Tailored specifically for React Router v6 βœ… 🚫 βœ…

Other libraries that I was able to find are outdated and not really suitable for React Router v6:

You might also want to use some other router with built-in type safety:

Quick usage example

Define routes:

import { route, number, boolean, hashValues } from "react-router-typesafe-routes/dom"; // Or /native

const ROUTES = {
    USER: route(
        // This is a normal path pattern, but without leading or trailing slashes.
        // By default, path params are inferred from the pattern.
        "user/:id",
        {
            // We can override some or all path params. Here, we override 'id'.
            // We specify that an error will be thrown in case of an absent/invalid param.
            // For demonstration purposes only, normally you shouldn't throw.
            params: { id: number().defined() },
            // These are search params.
            // We specify a default value to use in case of an absent/invalid param.
            searchParams: { infoVisible: boolean().default(false) },
            // These are state fields, which are similar to search params.
            // By default, 'undefined' is returned in case of an absent/invalid param.
            state: { fromUserList: boolean() },
            // These are allowed hash values.
            // We could also use hashValues() to indicate that any hash is allowed.
            hash: hashValues("info", "comments"),
        },
        // This is a child route, which inherits all parent params.
        // Note how it has to start with an uppercase letter.
        // As a reminder, its path params are inferred from the pattern.
        { DETAILS: route("details/:lang?") }
    ),
};

Use Route components as usual:

import { Route, Routes } from "react-router-dom"; // Or -native
import { ROUTES } from "./path/to/routes";

// Absolute paths
<Routes>
    {/* /user/:id */}
    <Route path={ROUTES.USER.path} element={<User />}>
        {/* /user/:id/details/:lang? */}
        <Route path={ROUTES.USER.DETAILS.path} element={<UserDetails />} />
    </Route>
</Routes>;

// Relative paths
<Routes>
    {/* user/:id */}
    <Route path={ROUTES.USER.relativePath} element={<User />}>
        {/* details/:lang? */}
        {/* $ effectively defines path pattern start. */}
        <Route path={ROUTES.USER.$.DETAILS.relativePath} element={<UserDetails />} />
    </Route>
</Routes>;

Use Link components as usual:

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

// Absolute link
<Link
    // Path params: { id: number; lang?: string } -- optionality is governed by the path pattern.
    // Search params: { infoVisible?: boolean } -- all params are optional.
    // State fields: { fromUserList?: boolean } -- all fields are optional.
    // Hash: "info" | "comments" | undefined
    to={ROUTES.USER.DETAILS.buildPath({ id: 1, lang: "en" }, { infoVisible: false }, "comments")}
    state={ROUTES.USER.DETAILS.buildState({ fromUserList: true })}
>
    /user/1/details/en?infoVisible=false#comments
</Link>;

// Relative link
<Link
    // Path params: { lang?: string } -- optionality is governed by the path pattern.
    // Other params remain the same.
    // $ effectively defines path pattern start.
    to={ROUTES.USER.$.DETAILS.buildRelativePath({ lang: "en" }, { infoVisible: true }, "info")}
    state={ROUTES.USER.DETAILS.buildState({ fromUserList: false })}
>
    details/en?infoVisible=true#info
</Link>;

Get typed path params with useTypedParams():

import { useTypedParams } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";

// The type here is { id: number; lang?: string }.
// Note how id can't be undefined because we throw an error in case of an absent/invalid param.
const { id, lang } = useTypedParams(ROUTES.USER.DETAILS);

Get typed search params with useTypedSearchParams():

import { useTypedSearchParams } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";

// The type here is { infoVisible: boolean }.
// Note how infoVisible can't be undefined because we specified a default value.
const [{ infoVisible }, setTypedSearchParams] = useTypedSearchParams(ROUTES.USER.DETAILS);

Get typed state with useTypedState():

import { useTypedState } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";

// The type here is { fromUserList: boolean | undefined }.
// Note how fromUserList can be undefined, which means that it's absent or invalid.
const { fromUserList } = useTypedState(ROUTES.USER.DETAILS);

Get typed hash with useTypedHash():

import { useTypedHash } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";

// The type here is "info" | "comments" | undefined.
const hash = useTypedHash(ROUTES.USER.DETAILS);

Advanced examples

Define unions and arrays:

import { route, union, number } from "react-router-typesafe-routes/dom"; // Or /native

const ROUTE = route("", {
    searchParams: {
        // Unions can contain any string, number, and boolean values.
        tab: union("info", "comments").default("info"),
        // Every built-in type can be used to create an array type.
        // Arrays can only be used for search params and state fields.
        // As expected, we can use '.default' and '.defined' for items.
        // If items are '.defined', an absent/invalid param will fail the whole array.
        selectedIds: number().default(-1).array(),
    },
});

Reuse types across routes:

import { route, types, number, string, useTypedSearchParams } from "react-router-typesafe-routes/dom"; // Or /native

const PAGINATION_FRAGMENT = route("", { searchParams: { page: number() } });

const ROUTES = {
    // This route uses pagination params and also has its own search params.
    USER: route("user", types({ searchParams: { q: string() } })(PAGINATION_FRAGMENT)),
    // This route only uses pagination params.
    POST: route("post", types(PAGINATION_FRAGMENT)),
    // This route doesn't use pagination params
    ABOUT: route("about"),
};

// We can use PAGINATION_FRAGMENT to get the page param anywhere:
const [{ page }] = useTypedSearchParams(PAGINATION_FRAGMENT);

Add custom validation:

import { route, string, number } from "react-router-typesafe-routes/dom"; // Or /native

// Note that we don't need to check that value is a number.
// This is possible because number() helper has this check built-it.
const integer = (value: number) => {
    if (!Number.isInteger(value)) {
        throw new Error(`Expected ${value} to be integer.`);
    }

    return value;
};

// We can construct validators however we want.
const regExp = (regExp: RegExp) => (value: string) => {
    if (value.match(regExp)?.[0] !== value) {
        throw new Error(`"${value}" does not match ${String(regExp)}`);
    }

    return value;
};

const ROUTE = route(":id", {
    // string() only accepts validators that return strings.
    params: { id: string(regExp(/\d+/)) },
    // number() only accepts validators that return numbers.
    searchParams: { page: number(integer) },
});

Use Zod:

import { route } from "react-router-typesafe-routes/dom"; // Or /native
import { zod } from "react-router-typesafe-routes/zod";
import { z } from "zod";

const ROUTE = route(":id", {
    // Wrapping quotes in serialized values are omitted where possible.
    params: { id: zod(z.string().uuid()) },
});

❗Zod doesn't do coercion by default, but you may need it for complex values returned from JSON.parse (for instance, a date wrapped in an object).

Use Yup:

import { route } from "react-router-typesafe-routes/dom"; // Or /native
import { yup } from "react-router-typesafe-routes/yup";
import { string } from "yup";

const ROUTE = route(":id", {
    // Wrapping quotes in serialized values are omitted where possible.
    params: { id: yup(string().uuid()) },
});

Integrate third-party validation library:

import { type, parser, UniversalType, ParserHint } from "react-router-typesafe-routes/dom"; // Or /native
// Schema is a library-specific interface.
import { v, Schema } from "third-party-library";

function valid<T>(schema: Schema<T>): UniversalType<T> {
    return type(
        // We use library-specific validation logic.
        (value: unknown) => schema.validate(value),
        // We can optionally provide a parser.
        // Built-in parser is used to remove wrapping quotes where possible.
        // We could also supply a custom parser.
        parser(getTypeHint(schema))
    );
}

function getTypeHint(schema: Schema): ParserHint {
    // We determine if the schema type is assignable to 'string' or 'date'.
    // If so, we return the corresponding hint, and 'unknown' otherwise.
    // The type can also be optional, e.g. 'string | undefined' should use 'string' hint.
    return schema.type;
}

const ROUTE = route(":id", {
    params: { id: valid(v.string().uuid()) },
});

Construct type objects manually to cover obscure use cases:

import { route, ParamType } from "react-router-typesafe-routes/dom"; // Or /native

// This type accepts 'string | number | boolean' and returns 'string'.
// We only implement ParamType interface, so this type can only be used for path params.
// For other params, we would need to implement SearchParamType and StateParamType.
const looseString: ParamType<string, string | number | boolean> = {
    getPlainParam(value) {
        // It's always guaranteed that value is not 'undefined' here.
        return String(value);
    },
    getTypedParam(value) {
        // We could treat 'undefined' in a special way to distinguish absent and invalid params.
        if (typeof value !== "string") {
            throw new Error("Expected string");
        }

        return value;
    },
};

const ROUTE = route(":id", {
    params: { id: looseString },
});

Concepts

Nesting

Library routes

Any route can be a child of another route. Child routes inherit everything from their parent.

Most of the time, it's easier to simply inline child routes:

import { route } from "react-router-typesafe-routes/dom"; // Or /native

const USER = route("user/:id", {}, { DETAILS: route("details") });

console.log(USER.path); // "/user/:id"
console.log(USER.DETAILS.path); // "/user/:id/details"

They can also be uninlined, most likely for usage in multiple places:

import { route } from "react-router-typesafe-routes/dom"; // Or /native

const DETAILS = route("details");

const USER = route("user/:id", {}, { DETAILS });
const POST = route("post/:id", {}, { DETAILS });

console.log(USER.DETAILS.path); // "/user/:id/details"
console.log(POST.DETAILS.path); // "/post/:id/details"
console.log(DETAILS.path); // "/details"

To reiterate, DETAILS and USER.DETAILS are separate routes, which will usually behave differently. DETAILS doesn't know anything about USER, but USER.DETAILS does. DETAILS is a standalone route, but USER.DETAILS is a child of USER.

❗Child routes have to start with an uppercase letter to prevent overlapping with route API.

Using routes in React Router <Route /> components

Routes structure usually corresponds to the structure of <Route /> components:

import { Route, Routes } from "react-router-dom"; // Or -native

<Routes>
    {/* '/user/:id' */}
    <Route path={USER.path} element={<User />}>
        {/* '/user/:id/details' */}
        <Route path={USER.DETAILS.path} element={<UserDetails />} />
    </Route>
</Routes>;

❗As a reminder, you have to render an <Outlet /> in the parent component.

However, nothing stops you from specifying additional routes as you see fit.

Note that we're using the path field here, which returns an absolute path pattern. React Router allows absolute child route paths if they match the parent path.

You're encouraged to use absolute path patterns whenever possible because they are easier to reason about.

❗ At the time of writing, there are quirks with optional path segments that may force the use of relative path patterns.

Relative paths can be used like this:

import { Route, Routes } from "react-router-dom"; // Or -native

<Routes>
    {/* 'user/:id' */}
    <Route path={USER.relativePath} element={<User />}>
        {/* 'details' */}
        <Route path={USER.$.DETAILS.relativePath} element={<UserDetails />} />
    </Route>
</Routes>;

That is, the $ property of every route contains child routes that lack parent path pattern. The mental model here is that $ defines the path pattern start.

The path property contains a combined path with a leading slash (/), and relativePath contains a combined path without intermediate stars (*) and without a leading slash (/).

Nested <Routes />

If your <Route/> is rendered in a nested <Routes />, you have to not only add a * to the parent path, but also exclude the parent path from the subsequent paths. This might change if this proposal goes through.

import { Route, Routes } from "react-router-dom"; // Or -native
import { route } from "react-router-typesafe-routes/dom"; // Or /native

const USER = route("user/:id/*", {}, { DETAILS: route("details") });

<Routes>
    {/* '/user/:id/*' */}
    <Route path={USER.path} element={<User />} />
</Routes>;

// Somewhere inside <User />
<Routes>
    {/* '/details' */}
    <Route path={USER.$.DETAILS.path} element={<UserDetails />} />
</Routes>;

❗ Star doesn't prevent subsequent routes from being rendered as direct children.

❗At the time of writing, there are some issues with nested <Routes /> if dynamic segments are used.

Typing

Type objects

Path params, search params, and state fields serializing, parsing, validation, and typing are done via type objects. Validation is done during parsing.

// Can be used for path params
interface ParamType<TOut, TIn = TOut> {
    getPlainParam: (originalValue: Exclude<TIn, undefined>) => string;
    getTypedParam: (plainValue: string | undefined) => TOut;
}

// Can be used for search params
interface SearchParamType<TOut, TIn = TOut> {
    getPlainSearchParam: (originalValue: Exclude<TIn, undefined>) => string[] | string;
    getTypedSearchParam: (plainValue: string[]) => TOut;
}

// Can be used for state fields
interface StateParamType<TOut, TIn = TOut> {
    getPlainStateParam: (originalValue: Exclude<TIn, undefined>) => unknown;
    getTypedStateParam: (plainValue: unknown) => TOut;
}

❗ It's guaranteed that undefined will never be passed as TIn.

These interfaces allow to express pretty much anything, though normally you should use the built-in helpers for constructing these objects. Manual construction should only be used if you're hitting some limitations.

Type helpers

To make type objects construction and usage easier, we impose a set of reasonable restrictions / design choices:

  • TIn and TOut are the same, for all params.
  • Type objects for arrays are constructed based on helpers for individual values. Array params can never be parsed/validated into undefined.
  • By default, parsing/validation errors result in undefined. We can also opt in to returning a default value or throwing an error in case of an absent/invalid param.
  • State params are only validated and not transformed in any way.
  • Type objects for individual values can be used for any param. Type objects for arrays can only be used for search params and state fields.

With this in mind, we can think about type objects in terms of parsers and validators.

Parser

Parser is simply a group of functions for transforming a value to string and back:

interface Parser<T> {
    stringify: (value: T) => string;
    // There are edge cases when this value can be different from T.
    // We always validate this value anyway.
    parse: (value: string) => unknown;
}

The library provides parser() helper for accessing the built-in parser. It can accept an optional type hint. By default, it simply behaves as JSON. It also has a special behavior for strings and dates, where it omits wrapping quotes in such serialized values.

Validator

Validator is simply a function for validating values:

interface Validator<T, TPrev = unknown> {
    (value: TPrev): T;
}

It returns a valid value or throws if that's impossible. It can transform values to make them valid.

The important thing is that it has to handle both the original value and whatever the corresponding parser returns.

Generic helper

The type() helper is used for creating all kinds of type objects. The resulting param type is inferred from the given validator.

import { type, parser, Validator } from "react-router-typesafe-routes/dom"; // Or /native

const positiveNumber: Validator<number> = (value: unknown): number => {
    if (typeof value !== "number" || value <= 0) {
        throw new Error("Expected positive number");
    }

    return value;
};

// The following types are equivalent (we use JSON as a parser).
// We could also supply a custom parser.
type(positiveNumber, parser("unknown"));
type(positiveNumber, parser());
type(positiveNumber);

The resulting type object will return undefined upon a parsing (or validation) error. We can change how absent/invalid params are treated:

// This will throw an error.
type(positiveNumber).defined();
// This will return the given value.
type(positiveNumber).default(1);

The .defined()/.default() modifiers guarantee that the parsing result is not undefined, even if the given validator can return it. Default values passed to .default() are validated.

We can also construct type objects for arrays:

// Upon parsing:

// This will give '(number | undefined)[]'.
// This should be the most common variant.
type(positiveNumber).array();

// This will give 'number[]'.
// Absent/invalid values will be replaced with '-1'.
type(positiveNumber).default(-1).array();

// This will give 'number[]'.
// Absent/invalid values will lead to an error.
type(positiveNumber).defined().array();

Arrays can only be used in search params and state fields, because there is no standard way to store arrays in path params. For state fields, if a value is not an array, it's parsed as an empty array.

Type-specific helpers

Most of the time, you should use type-specific helpers: string(), number(), boolean(), or date(). They are built on top of type(), but they have the corresponding parsers and type checks built-in.

For instance:

import { number, Validator } from "react-router-typesafe-routes/dom"; // Or /native

const positive: Validator<number, number> = (value: number): number => {
    if (value <= 0) {
        throw new Error("Expected positive number");
    }

    return value;
};

number(positive);
Third-party validation libraries

You can use Zod and Yup out-of-box, and you should be able to integrate any third-party validation library via the type() helper. See Advanced examples.

Gotchas:

  • It doesn't matter if a validator can accept or return undefined or not - it will be normalized by type() anyway.
  • A validator can receive undefined, which means that it can define its own default value, for example.

Hash values

Hash is typed via the hashValues() helper. You simply specify the allowed values. If none specified, anything is allowed.

Path params

Path params are inferred from the provided path pattern and can be overridden (partially or completely) with path type objects. Inferred params won't use any type object at all, and instead will simply be considered to be of type string.

Just as usual segments, dynamic segments (path params) can be made optional by adding a ? to the end. This also applies to star (*) segments.

import { route, number } from "react-router-typesafe-routes/dom"; // Or /native

// Here, id is overridden to be a number, and subId and optionalId are strings
const ROUTE = route("route/:id/:subId/:optionalId?", { params: { id: number() } });

Upon building, all path params except the optional ones are required. Star parameter (*) is always optional upon building.

Upon parsing, if some non-optional implicitly typed param is absent (even the star parameter, because React Router parses it as an empty string), the parsing fails with an error.

Explicitly typed params behave as usual.

❗ You most likely will never need it, but it's technically possible to provide a type object for the star parameter as well.

Search params

Search params are determined by the provided search type objects.

import { route, string } from "react-router-typesafe-routes/dom"; // Or /native

// Here, we define a search parameter 'filter' of 'string' type
const ROUTE = route("route", { searchParams: { filter: string() } });

All search parameters are optional.

State fields

State fields are determined by the provided state type objects. To make state merging possible, the state is assumed to always be an object.

import { route, boolean } from "react-router-typesafe-routes/dom"; // Or /native

// Here, we define a state parameter 'fromList' of 'boolean' type
const ROUTE = route("route", { state: { fromList: boolean() } });

All state fields are optional.

Hash

Hash doesn't use any type objects. Instead, you can specify the allowed values, or specify that any string is allowed (by calling the helper without parameters). By default, nothing is allowed as a hash value (otherwise, merging of hash values wouldn't work).

import { route, hashValues } from "react-router-typesafe-routes/dom"; // Or /native

const ROUTE_NO_HASH = route("route");

const ROUTE_DEFINED_HASH = route("route", { hash: hashValues("about", "more") });

const ROUTE_ANY_HASH = route("route", { hash: hashValues() });

Hash is always optional.

❗ Note that hashValues() is the equivalent of [] as const and is used only to make typing more convenient.

Nested routes

Child routes inherit all type objects from their parent. For parameters with the same name, child type objects take precedence. It also means that if a path parameter has no type object specified, it will use the parent type object for a parameter with the same name, if there is one.

❗ Parameters with the same name are discouraged.

Hash values are combined. If a parent allows any string to be a hash value, its children can't override that.

Child routes under $ don't inherit parent type objects for path params.

Types composition

It's pretty common to have completely unrelated routes that share the same set of params. One such example is pagination params.

We can use nesting and put common types to a single common route:

import { route, number, useTypedSearchParams } from "react-router-typesafe-routes/dom"; // Or /native

const ROUTE = route(
    "",
    { searchParams: { page: number() } },
    { USER: route("user"), POST: route("post"), ABOUT: route("about") }
);

// We can use this common ROUTE to get the page param anywhere:
const [{ page }] = useTypedSearchParams(ROUTE);

However, this approach has the following drawbacks:

  • All routes will have all common params, even if they don't need them.
  • All common params are defined in one place, which may get cluttered.
  • We can't share path params this way, because they require the corresponding path pattern.

To mitigate these issues, we can use type composition via the types() helper:

import { route, types, number, string, useTypedSearchParams } from "react-router-typesafe-routes/dom"; // Or /native

const PAGINATION_FRAGMENT = route("", { searchParams: { page: number() } });

const ROUTES = {
    // This route uses pagination params and also has its own search params.
    USER: route("user", types({ searchParams: { q: string() } })(PAGINATION_FRAGMENT)),
    // This route only uses pagination params.
    POST: route("post", types(PAGINATION_FRAGMENT)),
    // This route doesn't use pagination params
    ABOUT: route("about"),
};

// We can use PAGINATION_FRAGMENT to get the page param anywhere:
const [{ page }] = useTypedSearchParams(PAGINATION_FRAGMENT);

The types() helper accepts either a set of types (including hash values), or a route which types should be used, and returns a callable set of types, which can be called to add more types. We can compose any number of types, and they are merged in the same way as types in nested routes.

❗ Types for path params will only be used if the path pattern has the corresponding dynamic segments.

API

route()

A route is defined via the route() helper. It accepts required path and optional types and children. All types fields are optional.

import { route, string, number, boolean, hashValues } from "react-router-typesafe-routes/dom"; // Or /native

const ROUTE = route(
    "my/path",
    {
        params: { pathParam: string() },
        searchParams: { searchParam: number() },
        state: { stateParam: boolean() },
        hash: hashValues("value"),
    },
    { CHILD_ROUTE: route("child") }
);

The path argument is a path pattern that you would put to the path property of a <Route/>, but without leading or trailing slashes (/). More specifically, it can:

  • be a simple segment or a group of segments ('user', 'user/details').
  • have any number of dynamic segments (params) anywhere (':id/user', 'user/:id/more').
  • end with a star segment ('user/:id/*', '*')
  • have any number of optional segments (user?/:id?/*?)
  • be an empty string ('').

The types argument specifies type objects and hash values of the route. See Typing.

The children argument specifies child routes of the route. See Nesting.

The route() helper returns a route object, which has the following fields:

  • path and relativePath, where path contains a combined path pattern with a leading slash (/), and relativePath contains a combined path pattern without intermediate stars (*) and a leading slash (/). They can be passed to e.g. the path prop of React Router <Route/>.

    ❗ At the time of writing, patterns with optional segments can't be used in matchPath/useMatch.

  • buildPath() and buildRelativePath() for building parametrized URL paths (pathname + search + hash) which can be passed to e.g. the to prop of React Router <Link />.
  • buildState() for building typed states, which can be passed to e.g. the state prop of React Router <Link />.
  • buildSearch() and buildHash() for building parametrized URL parts. They can be used (in conjunction with buildState() and buildPath()/buildRelativePath()) to e.g. build a parametrized Location object.
  • getTypedParams(), getTypedSearchParams(), getTypedHash(), and getTypedState() for retrieving typed params from React Router primitives. Untyped params are omitted.
  • getUntypedParams(), getUntypedSearchParams(), and getUntypedState() for retrieving untyped params from React Router primitives. Typed params are omitted. Note that the hash is always typed.
  • getPlainParams() and getPlainSearchParams() for building React Router primitives from typed params. Note how hash and state don't need these functions because buildHash() and buildState() can be used instead.
  • types, which contains type objects and hash values of the route. Can be used for sharing types with other routes, though normally you should use the types() helper instead.
  • $, which contains child routes that lack the parent path pattern and the corresponding type objects.
  • Any number of child routes starting with an uppercase letter.

parser()

The built-in parser is exposed as parser(). It should only be used for creating custom wrappers around type().

It accepts the following type hints:

  • 'unknown' - the value is processed by JSON. This is the default.
  • 'string' - the value is not transformed in any way.
  • 'date' - the value is transformed to an ISO string.

type()

All type helpers are wrappers around type(). It's primarily exposed for integrating third-party validation libraries, but it can also be used directly, if needed.

See Typing: Type helpers.

There are built-in helpers for common types:

  • string(), number(), boolean(), date() - simple wrappers around type(), embed the corresponding parsers and type checks. Can accept validators that expect the corresponding types as an input.
  • union() - a wrapper around type() that describes unions of string, number, or boolean values. Can accept a readonly array or individual values.

There are also built-in helpers for third-party validation libraries:

  • zod() - a wrapper around type() for creating type objects based on Zod Types. Uses a separate entry point: react-router-typesafe-routes/zod.
  • yup() - a wrapper around type() for creating type objects based on Yup Schemas. Uses a separate entry point: react-router-typesafe-routes/yup.

All of them use the built-in parser with auto-detected hint.

All built-in helpers catch parsing and validation errors and replace them with undefined. This behavior can be altered with the following modifiers:

  • .default() - accepts a default value that is used instead of an absent/invalid param;
  • .defined() - specifies that an error is thrown in case of an absent/invalid param. For invalid params, the original error is used.

hashValues()

The hashValues() helper types the hash part of the URL. See Typing: Hash.

types()

The types() helper is used for types composition. See Typing: Types composition.

Hooks

All hooks are designed in such a way that they can be reimplemented in the userland. If something isn't working for you, you can get yourself unstuck by creating custom hooks.

Of course, you can still use React Router hooks as you see fit.

useTypedParams()

The useTypedParams() hook is a thin wrapper around React Router useParams(). It accepts a route object as the first parameter, and the rest of the API is basically the same, but everything is properly typed.

useTypedSearchParams()

The useTypedSearchParams() hook is a (somewhat) thin wrapper around React Router useSearchParams(). It accepts a route object as the first parameter, and the rest of the API is basically the same, but everything is properly typed.

The only notable difference is that setTypedSearchParams() has an additional preserveUntyped option. If true, existing untyped (by the given route) search parameters will remain intact. Note that this option does not affect the state option. That is, there is no way to preserve untyped state fields.

The reason for this is that useTypedSearchParams() is intended to be a simple wrapper around useSearchParams(), and the latter doesn't provide any access to the current state. If this proposal goes through, it would be very easy to implement, but for now, the only way to achieve this is to create a custom hook.

useTypedState()

The useTypedState() hook is a thin wrapper around React Router useLocation(). It accepts a route object as the first parameter and returns a typed state.

useTypedHash()

The useTypedHash() hook is a thin wrapper around React Router useLocation(). It accepts a route object as the first parameter and returns a typed hash.

react-router-typesafe-routes's People

Contributors

fenok avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

react-router-typesafe-routes's Issues

Can this be shared between a backend and frontend app?

My current setup is a monorepo where the URLs are shared between frontend app (using react-router) and a backend app (using node), my concern is that since this relies on react-router it will not work on the backend app, or am I missing something?

Error when bundling

Hi! I've just found this wonderful looking project and would love to use it in one of my project. However, when I bundle my project I get these errors. Any ideas?

✘ [ERROR] No matching export in "node_modules/react-router-dom/esm/react-router-dom.js" for import "useSearchParams"

    node_modules/react-router-typesafe-routes/dom/useTypedSearchParams.js:12:9:
      12 β”‚ import { useSearchParams, createSearchParams } from "react-router-dom";
         β•΅          ~~~~~~~~~~~~~~~

✘ [ERROR] No matching export in "node_modules/react-router-dom/esm/react-router-dom.js" for import "createSearchParams"

    node_modules/react-router-typesafe-routes/dom/useTypedSearchParams.js:12:26:
      12 β”‚ import { useSearchParams, createSearchParams } from "react-router-dom";
         β•΅                           ~~~~~~~~~~~~~~~~~~

✘ [ERROR] No matching export in "node_modules/react-router-dom/esm/react-router-dom.js" for import "createSearchParams"

    node_modules/react-router-typesafe-routes/dom/route.js:1:9:
      1 β”‚ import { createSearchParams, generatePath } from "react-router-dom";
        β•΅          ~~~~~~~~~~~~~~~~~~

Please let me know if there is any more information I can provide to help debug this.

Cannot declare nested routes

const routes = {
	root: route('', {}, { auth: route('auth', {}) }),
};

Throws

Argument of type '{ auth: RouteWithChildren<void, "auth", Record<never, never>, Record<never, never>, never[], Record<never, never>>; }' is not assignable to parameter of type 'undefined'.

I am using typescript 4.9.4.

v6 uninline child

Playing with the v6 branch.
It'd be nice if you could something like

const detailsRoutes = PRODUCT.DETAILS.uninline(PRODUCT).

So you could then use inline children to get the easy dot notation access, but break it apart when creating routes.

Get absolute path without indeterminate stars

Hello,

Thanks for your work on this, I believe it can really help my current project. My organization uses sub-routers to add shared wrappers/organize code specific to a particular kind of entity.

For example:

<Routes>
  <Route path={ROUTES.HOME.path} element={<HomePage />} />
  <Route path={ROUTES.USERS.path} element={<UserRouter />} />
</Routes>

UserRouter would render the following:

<Routes>
  <Route path={ROUTES.USERS.$.LIST.path} element={<UserListPage />} />
  <Route path={ROUTES.USERS.$.DETAILS.path} element={<UserDetailsPage />} />
</Routes>

ROUTES would look something like this:

export const ROUTES = {
  HOME: route(''),
  USERS: route(
    'users/*',
    {},
    {
      LIST: route(''),
      DETAILS: route(':id'),
    }
  ),
};

How would one navigate to ROUTES.USERS.LIST.path while excluding the indeterminate star introduced by USERS route? Does this project support that? relativePath omits it, as noted in the README, but I'm curious if this use case is supported.

console.log(ROUTES.USERS.LIST.path); // prints `users/*`, I'm looking to just print `users/`, potentially at depth (nested routers within nested routers)

What could go wrong if not using uppercase in route names?

Hey, great library!

I saw the in the docs that mention the following:

❗Child routes have to start with an uppercase letter to prevent overlapping with route API.

I was wondering if you could go more in detail of what this means, as I like my routes in lowercase haha.

Thanks!

Switch to the latest React Router version

Consider renaming buildUrl and buildRelativeUrl functions

Current names are misleading because these functions don't generate full URLs. Instead, they generate URL parts which are called paths in React Router. There already are buildPath and buildRelativePath functions, but they actually generate pathnames, so they can also be renamed.

These are the possible renamings:

  • buildUrl => buildPath
  • buildRelativeUrl => buildRelativePath
  • buildPath => buildPathname
  • buildRelativePath => buildRelativePathname

It won't even be a breaking change, because buildUrl can be safely used instead of buildPath.

`.buildPath` allows invalid arguments

I have an application with heavy use of searchParams and I'm looking for a way to make it harder for developers to invoke buildPath incorrectly.

In the example below, you can see that it's very easy to call buildPath with the searchParameters as the first argument.

I think this is because the first argument to buildPath in this situation is Record<never, never> instead of Record<string, never>.

Any tips on either:
(1) where to fix this in the library
(2) how to build the ROUTES differently to get type safety without any upstream fix
(3) a pointer on if I'm doing something completely wrong.

Thanks!

import { route, string } from 'react-router-typesafe-routes/dom';

const ROUTES = {
  WIDGETS: route('widgets', { searchParams: { order: string() } }),
};

// This is what I want to invoke:
ROUTES.WIDGETS.buildPath({}, { order: 'asc' }); // '/widgets?order=asc'

// This silently fails -- it passes the typescript tests
ROUTES.WIDGETS.buildPath({ order: 'asc' }); // '/widgets'

Route composition API

There are cases when routes can share the same set of parameters (most likely search parameters and state fields). One common example is pagination params.

We likely want a common helper for these parameters as well, so we need an actual route for them.

We could use inheritance, but it can quickly get ugly: PAGINATION.FILTER.ORDER.ACTUAL_ROUTE.buildUrl(...).

We can use route types, but it's relatively verbose:

const PAGINATION_FRAGMENT = route( '', { searchParams: { offset: numberType } } );

const LIST = route( 'list', { searchParams: { ...PAGINATION_FRAGMENT.types.searchParams, customParam: stringType } } )

It would be nice to have an API for route composition, something like this:

const LIST = route( 'list', {searchParams: { customParam: stringType }}, {}, [PAGINATION_FRAGMENT] );

However, I can't figure out how to write types for this (when there are multiple fragments).

For path `'/'` error TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.

Hi, when I try to use the path value '/' for a route I get the error:

TS2345: Argument of type 'string' is not assignable to parameter of type 'never'. 

This happens only for this value. Feels like there's an explicit check for this value in particular, but I wonder why since I have been always able to use it without issues with react-router?

export const ROUTES = {
  DEFAULT: route('/'), // <- TS2345: Argument of type 'string' is not assignable to parameter of type 'never'. 
};
<Routes>
    <Route
        path={'/'}  // <- not a problem
        element={<MyComponent />}
    />

Merging typed and non-typed search params

As of now, if we want to implement a common helper which only handles a portion of search params, but leaves other search params intact, we have to resort to using URLSearchParams object.

Thoughts:

  • We could add an option for setTypedSearchParams (of useTypedSearchParams), like preserveNonTyped. If true, the given object is merged with URLSearchParams object (containing only non-typed params).

Questions:

  • Do we need something similar for build* APIs?
  • Do we need to return restUrlSearchParams: URLSearchParams from useTypedSearchParams?

Consider separating parsing and validation

As of now, parsing and validation are both done via getTyped method. This leads to the following drawbacks:

  • Fallbacks are returned as-is, because they represent parsed values, and there is no way to validate what is already parsed. Therefore, it's possible to specify a fallback that violates validation rules.
  • Types of useTypedSearchParams could be slightly improved. We can specify initial search params, which behave similarly to fallbacks. If we knew which types don't include validation, we could type the corresponding returned params as non-undefined regardless of the fallbacks of their types. Or we could change useTypedSearchParams to accept actual fallbacks and just validate them.

We could split getTyped into required getTyped and optional validate, but it might be inconvenient for creating custom types.

Overall, it's not clear which approach is best.

[Question] Type constraints when passing routes

Hello! Thank you for a very useful library, I enjoy using it in my React projects πŸ‘

I am stuck with implementing a specific scenario. The idea is that route prop should only accept routes with id param in it.
I pass a proper type to Route.TPathTypes, so that I get TS error when trying to pass a route that does not have id param.
But I'm not sure how to figure out a constraint for Route.TPath in the same way.

const idParams = types({ params: { id: string() } }).params;
type IdRoute = Route<string, typeof idParams, any, any, any>;

type Props = {
  route?: IdRoute;
}

const Component = ({ route }: Props) => {
  const path = route.buildPath({})
}

This gives an error as expected.
image

But here, I expect typescript to give me an error as well since I don't pass an id here.
image

v6 Direction for path param parsing

We started playing with this package for v6 (which is looking great πŸ‘ ) when it was using parsers like numberParser these threw if it was bad input.
Now with the move to numberType you either pass a fallback or the returned value could be undefined which requires checking for bad input in every route or (just swallowing it by passing -1 or NaN)

Whilst the errors were jarring at first, using an ErrorBoundary you could handle them globally or per nested set of routes, especially if the error thrown was rich.

Curious on what you are thinking for handling parse errors going forward (just rely on fallbacks, is there an option to still throw)?

Add CommonJS support

I'm using react-router-typesafe-routes in a TypeScript project that compiles with Vite. Vite has no issues with the ESM-only output of this package, but I am unable to run my Playwright tests because they require CommonJS imports.

Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/user/Desktop/path/to/project/node_modules/react-router-typesafe-routes/dom/index.js from /Users/user/Desktop/ruter/path/to/project/src/routes.ts not supported.
Instead change the require of index.js in /Users/jonasjensen/Desktop/ruter/bestillingstransport/apps/bt-frontend/src/admin/routing/routes.ts to a dynamic import() which is available in all CommonJS modules.

   at ../src/admin/routing/routes.ts:7

   5 |   OVERVIEW: route('overview'),
   6 |   PROFILE: route('profile'),
>  7 |   TRIPS: route('trips', {}, {LIVE: route('live')}),
     |            ^
   8 |   MORE_ROUTES: route('more_routes', {}, {CLIENTS: route('clients')}),

I unfortunately discovered this only after I'd already rewrite all hardcoded paths in my entire codebase to use react-router-typesafe-routes. 😬 It would be very helpful if the package could support both ESM and CommonJS formats.

It would be great if react-router-typesafe-routes could provide a CommonJS output alongside the ESM output. This would make the package compatible with various testing environments and setups, including those that rely on CommonJS.

(As a workaround, I've tried bundling and transforming my tests using esbuild, but this adds too much extra complexity and I couldn't even get it to work properly..)

Thank you for considering this feature request, and I appreciate your efforts in creating and maintaining this package! I compared all the different "typesafe routes" packages that I could find and this one was by far the best I could find. πŸ™Œ

Absolute nested routes don't seem to work

First off, thanks for creating this library, I love the concept. It's very possible I'm doing something wrong here.

Your readme says "React Router allows absolute child route paths if they match the parent path." I have a pretty simple structure, and I was able to get it working with relative paths, but not absolute.

I have a parent route /regions/:regionSlug, and the then a child: /regions/:regionSlug/clusters/:id/:clusterName. The child never seems to match.

Here is the typed definition:

const routes = {
  Region: route(
    "regions/:regionSlug",
    {},
    {
      Clusters: route(
        "clusters",
        {},
        {
          List: route(""),
          Details: route(":id/:clusterName"),
        }
      ),
    }
  ),
};

Here is are the actual routes:

<Route path={routes.Region.path} element={<Region />}>
  <Route
    path={routes.Region.Clusters.Details.path}
    element={<ClusterDetails />}
  />
</Route>

The region route matches, but the nested route doesn't.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.