Comprehensive type-safe routes for react-router v6 with first-class support for nested routes and param validation.
âš For react-router v5, see v0.3.2.
The library provides extensible type safety via validation for path params, search params, state, and hash on building and parsing URLs and state objects, including nested routes.
yarn add react-router-typesafe-routes
The library is distributed as an ES module written in ES6.
react-router-typesafe-routes
contains core platform-independent functionality which is re-exported from platform-specific entry points: react-router-typesafe-routes/dom
for web and react-router-typesafe-routes/native
for React Native.
- 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.
- To make params merging possible, state has to be an object, and hash has to be one of the predefined strings (or any string).
- Since react-router only considers path on routes matching, search parameters, state fields, and hash are considered optional upon URL or state building.
- Since react-router doesn't support any kind of param validation, explicitly typed parameters (or state fields) are
undefined
in case of a parsing error (or parameter absence) upon URL or state parsing (though fallbacks can be used to preventundefined
values). Implicitly typed path parameters are neverundefined
, because they are returned as-is (and parameter absence leads to an error). - There are no fallbacks for hash, because they don't seem to be necessary.
-
typed-react-router only handles path params. It also forces the use of Route Objects and doesn't allow relative links.
-
typesafe-routes (as well as seemingly based on it react-typesafe-routes) only handles path and search params. It wasn't developed with modern react-router in mind and therefore doesn't play well with it.
-
typesafe-react-router only handles path params and has no concept of nested routes.
-
The solution described at Type-Safe Usage of React Router only cares about path params and also has no concept of nested routes.
-
There is also type-route, but it's still in beta. It's also a separate routing library.
Route definition may look like this:
import { numberType, booleanType, hashValues, route } from "react-router-typesafe-routes/dom"; // Or /native
const ROUTES = {
PRODUCT: route(
"product/:id",
{
searchParams: { sectionsCount: numberType },
hash: hashValues("about", "more"),
state: { fromProductList: booleanType },
},
{ DETAILS: route("details") }
),
};
Use Route
components as usual:
import { Route } from "react-router";
import { ROUTES } from "./path/to/routes";
<Routes>
<Route path={ROUTES.PRODUCT.path} element={<Product />}>
<Route path={ROUTES.PRODUCT.DETAILS.path} element={<ProductDetails />} />
</Route>
</Routes>;
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.DETAILS.buildUrl({ id: "8592f5d5" }, { sectionsCount: 20 }, "about")}
state={ROUTES.PRODUCT.DETAILS.buildState({ fromProductList: true })}
>
/product/8592f5d5/details?sectionsCount=20#about
</Link>;
Get typed path params with useTypedParams()
:
import { useTypedParams } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";
// Everything is fully typed!
const { id } = useTypedParams(ROUTES.PRODUCT.DETAILS);
Get typed search params with useTypedSearchParams()
:
import { useTypedSearchParams } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";
// Everything is fully typed!
const [{ sectionsCount }, setTypedSearchParams] = useTypedSearchParams(ROUTES.PRODUCT.DETAILS);
Get typed hash with useTypedHash()
:
import { useTypedHash } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";
// Everything is fully typed!
const hash = useTypedHash(ROUTES.PRODUCT.DETAILS);
Get typed state with useTypedState()
:
import { useTypedState } from "react-router-typesafe-routes/dom"; // Or /native
import { ROUTES } from "./path/to/routes";
// Everything is fully typed!
const { fromProductList } = useTypedState(ROUTES.PRODUCT.DETAILS);
Any route can be a child of another route:
const DETAILS = route("details");
const PRODUCT = route("product/:id", {}, { DETAILS });
Sure enough, you can also inline child routes:
const PRODUCT = route("product/:id", {}, { DETAILS: route("details") });
And you can uninline them later:
PRODUCT.$.DETAILS === DETAILS;
That is, the $
property of every route contains original routes, specified as children of that route.
It's important to understand that DETAILS
and PRODUCT.DETAILS
are separate routes, which may behave differently during parsing or building URLs. DETAILS
doesn't know anything about PRODUCT
, but PRODUCT.DETAILS
does. DETAILS
is a standalone route, but PRODUCT.DETAILS
is a child of PRODUCT
.
Child routes has to be in CONSTANT_CASE or PascalCase to prevent overlapping with other route fields.
These child routes correspond to child routes in react-router:
<Routes>
{/* '/product/:id' */}
<Route path={PRODUCT.path} element={<Product />}>
{/* '/product/:id/details' */}
<Route path={PRODUCT.DETAILS.path} element={<ProductDetails />} />
</Route>
</Routes>
React-router allows absolute paths in child routes, if they match the parent path.
Note that we're using the path
field here, which returns an absolute path. This ensures that the path provided to react-router is actually defined by the library.
You're encouraged to use the
path
field whenever possible.
As an escape hatch, you can use relative paths (note that you can use PRODUCT.$.DETAILS.relativePath
instead of DETAILS.relativePath
):
<Routes>
{/* '/product/:id' */}
<Route path={PRODUCT.path} element={<Product />}>
{/* 'details' */}
<Route path={DETAILS.relativePath} element={<ProductDetails />} />
</Route>
</Routes>
path
contains a combined path with a leading slash (/
), andrelativePath
contains a combined path without intermediate stars (*
) and without a leading slash (/
).
If your <Route/>
is not a direct child of another <Route />
, not only you have to add a *
to the parent path, but also exclude it from subsequent paths. This is because each <Routes/>
requires its own absolute paths.
const PRODUCT = route("product/:id/*", {}, { DETAILS: route("details") });
<Routes>
{/* '/product/:id/*' */}
<Route path={PRODUCT.path} element={<Product />} />
</Routes>;
// Somewhere inside <Product />
<Routes>
{/* '/details' */}
<Route path={PRODUCT.$.DETAILS.path} element={<ProductDetails />} />
</Routes>;
Note that star doesn't necessarily mean that the subsequent routes can't be rendered as direct children.
Params typing and validation is done via type objects. There are several built-in types, and there is createType()
helper for creating custom types. Hash is typed via the hashValues()
helper.
You are encouraged to write your own types as needed.
If parsing fails (including the case when the corresponding parameter is absent), undefined
is returned instead. In case of types, you can specify a fallback to use instead of undefined
(and TS is aware of that):
const ROUTE = route("my/route", { searchParams: { param: numberType(100) } });
There are no fallbacks for hash, though.
Returning undefined
for invalid or absent params provides flexibility, but sometimes throwing is preferable instead, especially for path params. In the future, the library may provide an option for that, but for now you can do it in the userland.
You can assert individual params:
function assertIsRequired<T>(value: T | undefined): asserts value is T {
if (value === undefined) throw new Error("Unexpected undefined");
}
// In some component
const { id } = useTypedParams(MY_ROUTE);
assertIsRequired(id); // After this line TS knows that id is not undefined
Or you can assert the whole object:
function required<T extends Record<string, unknown>>(obj: T): Required<T> {
if (Object.values(obj).includes(undefined)) throw new Error("Unexpected undefined");
return obj as Required<T>;
}
// In some component
const { id } = required(useTypedParams(MY_ROUTE)); // TS knows that id is not undefined
Path params are inferred from the provided path
and can be overridden (partially or completely) with path types. Inferred params won't use any type at all, and instead will simply be considered to be of type string
.
// Here, 'id' is parsed with a number type, and 'subId' implicitly has a 'string' type
const ROUTE = route("route/:id/:subId", { params: { id: numberType } });
Upon building, all path params are required, except for the star (*
) parameter.
Upon parsing, if some 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 shouldn't ever need to provide a type for the star parameter, but it's technically possible.
Search params are determined by the provided search types.
// Here, we define a search parameter 'filter' of 'string' type
const ROUTE = route("route", { searchParams: { filter: stringType } });
All search parameters are optional.
State fields are determined by the provided state types. To make state merging possible, the state is assumed to always be an object.
// Here, we define a state parameter 'fromList' of 'boolean' type
const ROUTE = route("route", { state: { fromList: booleanType } });
All state fields are optional.
Note that built-in types convert the given values to string
(or string[]
), which is not required in case of state, because it can contain any serializable value. In the future, the library may provide types specifically for state, but in the meantime they can be implemented in the userland.
Hash doesn't use any types. 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).
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.
Child routes implicitly have all parameters of their parents. For parameters with the same name, child types take precedence.
Parameters with the same name are discouraged.
Note that an explicit parent path param will take precedence of an implicit child path param.
Hash values are combined. If a parent allows any string
to be a hash value, its children can't override that.
A route is defined via the route()
helper. It accepts required path
and options
, and optional children
. All options
are optional.
const ROUTE = route(
"my/path",
{
params: { pathParam: stringType },
searchParams: { searchParam: numberType },
hash: hashValues("value"),
state: { stateParam: booleanType },
},
{ CHILD_ROUTE }
);
The path
argument is what 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 (
'product'
,'product/details'
). - have any number of parameters anywhere (
':id/product'
,'product/:id/more'
). - end with a star (
'product/:id/*'
,'*'
) - be an empty string (
''
).
The options
argument specifies types of the route. See "How typing works".
The children
argument specifies child routes of the route. See "How nesting works".
The route()
helper returns a route object, which has the following fields:
path
andrelativePath
, which can be passed to thepath
prop of react-router<Route/>
.buildUrl()
andbuildRelativeUrl()
for building parametrized URLs which can be passed to e.g. theto
prop of react-router<Link />
.buildState()
for building typed states, which can be passed to e.g. thestate
prop of react-router<Link />
.buildPath()
,buildRelativePath()
,buildSearch()
, andbuildHash()
for building parametrized URL parts. They can be used (in conjunction withbuildState()
) to e.g. build a parametrizedLocation
object.getTypedParams()
,getTypedSearchParams()
,getTypedHash()
, andgetTypedState()
for retrieving typed params from react-router primitives.getPlainParams()
andgetPlainSearchParams()
for building react-router primitives from typed params. Note how hash and state don't need these functions, becausebuildHash()
andbuildState()
can be used instead.$
, which contains original routes, specified as child routes of that route. These routes are unaffected by the parent route.- Any number of child routes in CONSTANT_CASE or PascalCase.
There are also some internal fields prefixed with __
.
stringType
-string
, stringified as-is.numberType
-number
, stringified byJSON.stringify
.booleanType
-boolean
, stringified byJSON.stringify
.dateType
-Date
, stringified as an ISO string.oneOfType
- one of the givenstring
,number
, orboolean
values, internally uses the respective built-in types. E.g.oneOfType('foo', 1, true)
means'foo' | 1 | true
.arrayOfType
- array of any given type, e.g.arrayOfType(oneOfType(1, 2))
means(1 | 2)[]
. Stringified asstring[]
, so it can only be used for search params or state.
All built-in types can be called to specify a fallback.
The createType()
helper is used to create custom types. It accepts a type object (strictly speaking, it simply decorates the given type, adding the fallback functionality):
interface Type<TOriginal, TPlain = string, TRetrieved = TOriginal> {
getPlain: (originalValue: TOriginal) => TPlain;
getTyped: (plainValue: unknown) => TRetrieved;
isArray?: boolean;
}
-
TOriginal
is what you want to store (in a URL or state) andTRetrieved
is what you will get back. They are different to support cases such as "number in - string out". -
TPlain
is how your value is stored. It's typicallystring
, but can also bestring[]
for arrays in search string. You can also store anything that can be serialized in state. -
getPlain()
transforms the given value fromTOriginal
intoTPlain
. -
getTyped()
tries to getTRetrieved
from the given value and throws if that's impossible. The givenplainValue
is typed asunknown
to emphasize that it may differ from what was returned bygetPlan()
(it may be absent or invalid). Note that the library catches this error and returnsundefined
instead. -
isArray
is a helper flag specific forURLSearchParams
, so we know when to.get()
and when to.getAll()
.
All built-in types are created via this helper.
The hashValues()
helper types the hash part of the URL. See "How typing works - Hash".
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.
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. One notable difference is that setSearchParams()
can also accept a callback, which will be called with the current search params.
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.
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.