react-router-typesafe-routes

repository·dev·Indexed 41 days ago

https://github.com/fenok/react-router-typesafe-routes

Enhanced type safety via validation for all route params in React Router v7. It provides end-to-end typing for pathname params, search params, state, and hash using built-in types or third-party validation libraries like Zod, Yup, and Standard Schema. Version 2.2.1 requires TypeScript 5.0+ and strict mode.

Tokens
11.6K
Snippets
29
Records
41
Agent score
47%

What's inside react-router-typesafe-routes

  1. How route nesting works

    dev

    Routes can be nested to create hierarchical URL structures. Child routes inherit properties from their parents. You can define children in two ways:

    1. Inlined: Defining children directly within the route() configuration.
    2. Uninlined: Defining a route separately and passing it to the children property of another route. Note that an uninlined route is a standalone route and does not automatically know about its parent's context unless it is explicitly assigned as a child.

    Important: Child route names cannot start with $ to avoid conflicts with the library's internal API.

    import { route } from "react-router-typesafe-routes";
    
    // Inlined nesting
    const user = route({ 
      path: "user/:id", 
      children: { details: route({ path: "details" }) } 
    });
    
    console.log(user.$path()); // "/user/:id"
    console.log(user.details.$path()); // "/user/:id/details"
    
    // Uninlined nesting
    const details = route({ path: "details" });
    const user = route({ path: "user/:id", children: { details } });
    
    console.log(user.details.$path()); // "/user/:id/details"
    console.log(details.$path()); // "/details"
  2. Understand param serialization and deserialization

    dev

    The library manages how JavaScript values are converted to and from URL components through several processes:

    • Serialization: Converting a JS value into a string (for URL params) or a serializable format (for state).
      • Stringification: The final step of serialization, converting a value to a string for URL params.
    • Deserialization: Transforming a string or serializable value back into a structured format and checking its type.
      • Parsing: The first step of deserialization, converting a string back into a value.
      • Validation: The final step of deserialization, checking the value against type restrictions and potentially transforming it to be valid.
  3. How react-router-typesafe-routes works

    dev

    The library provides end-to-end type safety for all React Router v7 route parts:

    • Pathname params
    • Search params (including multiple identical keys)
    • State
    • Hash

    It works by defining a route tree that handles both the building (serialization) and parsing/validating (deserialization) of URL parts.

    Key Concepts:

    • Validation: You can use built-in types (like string(), number(), boolean()) or external libraries (Zod, Yup, Standard Schema).
    • Error Handling: In built-in types, parsing errors are caught and replaced with undefined. You can also configure default values or throw errors.
    • Inheritance: Child routes automatically inherit all parameters (search, state, etc.) defined in their parent routes.
    • No Unsafe Casts: The library ensures type safety without using any or unsafe type assertions.
    • Non-destructive: It does not alter the React Router API and supports nested routes and relative links.
  4. Understand Type Inheritance and Priority

    dev

    When working with nested routes, understanding how types are resolved is critical:

    Inheritance

    • Child routes inherit all type objects from their parent.
    • For params with the same name, the child's type object takes precedence.
    • Hash inheritance: Separate hash values can only be inherited if the parent defines the hash as an array of strings.

    Priority Rules

    If multiple types exist for the same parameter, they are resolved in this order (lowest to highest priority):

    1. Implicit pathname types
    2. Inherited types
    3. Composed types
    4. Explicit types

    Special Overrides:

    • If a hash is defined as an array of strings AND a hash type object, the array wins.
    • If state is defined as a set of fields AND a whole state type, the whole state type wins.
  5. Share types between routes using pathless routes

    dev

    You can create reusable route fragments (pathless routes) to share parameter or search parameter types across multiple routes.

    • Pathless Routes: Define a route without a path property to act as a type fragment.
    • Composition: Use the compose array in a route definition to include types from these fragments.
    • Usage: Use useTypedParams or useTypedSearchParams with the fragment to extract the shared types in your components.
    import { route, useTypedParams, useTypedSearchParams } from "react-router-typesafe-routes";
    
    const fragments = {
      id: route({
        params: { id: number() },
      }),
      query: route({
        searchParams: { query: string() },
      }),
    };
    
    const root = route({
      searchParams: { utm_campaign: string() },
      compose: [fragments.id],
      children: {
        user: route({ path: "user/:id", compose: [fragments.query] }),
        post: route({ path: "post/:id" }),
        about: route({ path: "about" }),
      },
    });
    
    const { id } = useTypedParams(fragments.id);
    const [{ query }] = useTypedSearchParams(fragments.query);
    const [{ utm_campaign }] = useTypedSearchParams(root);
  6. Integrate routes with React Router

    dev

    Once your route tree is defined, use the generated $path() methods to provide paths to React Router's <Route /> components.

    Absolute vs Relative Paths:

    • Absolute: Use root.path.$path() for standard absolute routing.
    • Relative: Use root.path.$path({ relative: true }) for relative routing. When using relative paths, you must use the $ symbol to indicate the start of the path pattern (e.g., root.user.$.post.$path(...)).

    Note: Leading slashes in path patterns are forbidden to ensure the library correctly governs route relativity.

    import { Route, Routes } from "react-router";
    import { root } from "./path/to/routes";
    
    // Absolute paths
    <Routes>
      <Route path={root.user.$path()} element={<User />}>
        <Route path={root.user.post.$path()} element={<Post />} />
      </Route>
    </Routes>
    
    // Relative paths
    <Routes>
      <Route path={root.user.$path({ relative: true })} element={<User />}>
        <Route path={root.user.$.post.$path({ relative: true })} element={<Post />} />
      </Route>
    </Routes>
  7. Install react-router-typesafe-routes

    dev

    Install the library using your preferred package manager. Note that react-router and react are required peer dependencies.

    To use third-party validation libraries, you can use specific entry points:

    • react-router-typesafe-routes/standard-schema: For any Standard Schema compatible library.
    • react-router-typesafe-routes/zod: Requires zod as a peer dependency.
    • react-router-typesafe-routes/yup: Requires yup as a peer dependency.

    Requirements:

    • TypeScript 5.0 or higher.
    • strict mode must be enabled in tsconfig.json.
    yarn add react-router-typesafe-routes
  8. Preserve unrelated search params when navigating or updating

    dev

    When using useTypedSearchParams or $buildSearch, you can preserve search parameters that are not explicitly typed by the current route.

    1. In JSX (Link component): Pass the raw search params from useSearchParams() to the untypedSearchParams option in $buildSearch.
    2. In event handlers (setTypedSearchParams): Set the untypedSearchParams option to true in the options object to ensure parameters not defined in the route schema are kept.
    import { route, string, number, useTypedSearchParams } from "react-router-typesafe-routes";
    import { useSearchParams } from "react-router";
    
    export const searchParamsFragment = route({
      searchParams: {
        search: string(),
        page: number(),
      },
    });
    
    // In a component body
    const [typedSearchParams, setTypedSearchParams] = useTypedSearchParams(searchParamsFragment);
    const rawSearchParams = useSearchParams();
    
    // In JSX
    <Link
      to={searchParamsFragment.$buildSearch({
        searchParams: {
          ...typedSearchParams,
          page: 1,
        },
        untypedSearchParams: rawSearchParams,
      })}
    >
      Click
    </Link>;
    
    // In an event handler
    setTypedSearchParams(
      (prevParams) => ({
        ...prevParams,
        page: 1,
      }),
      {
        untypedSearchParams: true,
      },
    );
  9. Migrate to Universal Types syntax

    dev

    When upgrading to the Universal Types syntax (introduced in v2.0.0), the way route parameters and state are defined changes from specific type functions (like stringType) to a chainable method syntax (like string()).

    Key Syntax Changes

    Old syntaxNew syntax
    stringTypestring()
    numberTypenumber()
    booleanTypeboolean()
    dateTypedate()
    oneOfType('foo', 'bar')union(['foo', 'bar'])
    stringType('')string().default('')
    stringType(throwable)string().defined()
    arrayOfType(stringType)string().array() or string().defined().array()
    arrayOfType(stringType(''))string().default('').array()
    arrayOfType(stringType(throwable))string().defined().array()

    Important Behavioral Differences

    1. State Validation: In the new syntax, for state fields, the value (or its parts) is only validated; it is not stringified or parsed.
    2. Handling Undefined: With the new syntax, if you use .default() or .defined(), undefined cannot be returned.
    3. Array Behavior:
      • In the new syntax, arrays are always defined.
      • string().array() always returns an array. Invalid items are replaced with undefined (Note: as of v2.0.0, undefined values are omitted from the resulting array).
      • If you need the array to fail if any item is invalid, use string().defined().array(). This will throw an error if any item is absent or invalid.
      • There is no direct equivalent for the old arrayOfType(/* */)([]) or arrayOfType(/* */)(throwable) patterns because arrays are now always defined, and error handling is managed at the item level rather than the array level.
  10. Using library routes with React Router components

    dev

    To integrate with react-router-dom, use the $path() method from your route objects to populate the path prop of <Route /> components.

    Absolute Paths

    By default, $path() returns an absolute path pattern (starting with /). This is the recommended approach for most use cases.

    Relative Paths

    If you need to use relative paths (e.g., inside a nested <Routes /> or when dealing with specific React Router quirks), use $path({ relative: true }). This returns a path without a leading slash and without intermediate stars (*). To access child routes relatively, use the $ property of the parent route.

    Note: When rendering a parent route that contains nested <Routes />, you must append /* to the parent's path pattern in the library configuration.

    import { Route, Routes } from "react-router-dom";
    import { route } from "react-router-typesafe-routes";
    
    const user = route({ path: "user/:id/*", children: { details: route({ path: "details" }) } });
    
    // Parent Route
    <Routes>
      <Route path={user.$path()} element={<User />} />
    </Routes>
    
    // Inside <User /> component
    <Routes>
      <Route path={user.$.details.$path()} element={<UserDetails />} />
    </Routes>
  11. Define a type-safe route tree

    dev

    Use the route function to define your application's routing structure. You can define global parameters at a root level (even for pathless routes) which will then be inherited by all children.

    Common modifiers for built-in types:

    • .default(value): Provides a fallback value if the parameter is absent or invalid.
    • .defined(): Makes a parameter required (will throw upon parsing if absent).

    Example of a route tree with nested children, search params, hash, and state:

    import { route, string, number, boolean, union } from "react-router-typesafe-routes";
    
    const root = route({
      // Global search param with a default value
      searchParams: { utm_campaign: string().default("default_campaign") },
      children: {
        user: route({
          path: "user/:userId",
          params: { userId: number() },
          hash: union(["info", "comments"]),
          state: { fromUserList: boolean() },
          children: {
            post: route({ path: "post/:postId?" }),
          },
        }),
      },
    });
  12. Build type-safe links with $buildPath and $buildState

    dev

    Use $buildPath to generate a URL string and $buildState to generate the state object for <Link /> or navigate(). This ensures that all required parameters, search params, and hashes are provided and correctly typed.

    • $buildPath: Accepts params, searchParams, hash, and an optional relative flag.
    • $buildState: Accepts the state object defined in your route configuration.
    import { Link } from "react-router";
    import { root } from "./path/to/routes";
    
    // Absolute link
    <Link
      to={root.user.post.$buildPath({
        params: { userId: 1, postId: "abc" },
        searchParams: { utm_campaign: "campaign" },
        hash: "comments",
      })}
      state={root.user.post.$buildState({ fromUserList: true })}
    >
      /user/1/post/abc?utm_campaign=campaign#comments
    </Link>
    
    // Relative link
    <Link
      to={root.user.$.post.$buildPath({
        relative: true,
        params: { postId: "abc" },
        searchParams: { utm_campaign: "campaign" },
        hash: "info",
      })}
      state={root.user.post.$buildState({ fromUserList: false })}
    >
      post/abc?utm_campaign=campaign#info
    </Link>