Search code examples
reactjstypescriptreact-router-domreact-typescript

React router 6+ : how to strongly type the params option in route loader?


Have router

export const router = createBrowserRouter([
  {
    path: '/todos/:todoId',
    element: <Todo />,
    loader: todoLoader,
  }
]);

Have loader

export const loader: LoaderFunction = async ({ params }) => {
  return await fetchData(params.todoId);
};

How to type params according to path?

Waiting for the highlighting of the specified parameters in the path.

UPDATE: Thanks @DrewReese @LindaPaiste came to the following solution.

const PathNames = {
  todoDetail: '/todos/:idTodo',
} as const;

interface Args extends ActionFunctionArgs {
  params: Params<ParamParseKey<typeof PathNames.todoDetail>>;
}

export const loader: LoaderFunction = async ({ params }:Args) => {
  return await fetchData(params.todoId);
};

Solution

  • Primary Approach

    declare type _PathParam<Path extends string> = Path extends `${infer L}/${infer R}` ? _PathParam<L> | _PathParam<R> : Path extends `:${infer Param}` ? Param extends `${infer Optional}?` ? Optional : Param : never;
    /**
     * Examples:
     * "/a/b/*" -> "*"
     * ":a" -> "a"
     * "/a/:b" -> "b"
     * "/a/blahblahblah:b" -> "b"
     * "/:a/:b" -> "a" | "b" 
     * "/:a/b/:c/*" -> "a" | "c" | "*"
     */
    declare type PathParam<Path extends string> = Path extends "*" ? "*" : Path extends `${infer Rest}/*` ? "*" | _PathParam<Rest> : _PathParam<Path>;
    export declare type ParamParseKey<Segment extends string> = [
        PathParam<Segment>
    ] extends [never] ? string : PathParam<Segment>;
    /**
     * The parameters that were parsed from the URL path.
     */
    export declare type Params<Key extends string = string> = {
        readonly [key in Key]: string | undefined;
    };
    

    Declare a map/object of paths (key - value) that you can use RRD's Params and ParamParseKey utility types to extract the route path parameters (Credit to LindaPaiste and Qvazi for method).

    const Paths = {
      todoDetail: "/todos/:idTodo",
    } as const;
    
    interface TodoLoaderArgs extends ActionFunctionArgs {
      params: Params<ParamParseKey<typeof Paths.todoDetail>>;
    }
    
    const todoLoader: LoaderFunction = async ({ params }: TodoLoaderArgs) => {
      return await fetchData(params.idTodo);
    };
    

    enter image description here

    const router = createBrowserRouter([
      {
        path: Paths.todoDetail,
        element: <Todo />,
        loader: todoLoader as LoaderFunction
      },
    ]);
    

    However, the fetchData function expects a string type, so there's an incompatibility here since params.idTodo is typed as string | undefined. It's fixed by doing a conditional check before accessing

    const todoLoader: LoaderFunction = async ({ params }: TodoLoaderArgs) => {
      return params.idTodo ? await fetchData(params.idTodo) : null;
    };
    

    or asserting it's non-null, e.g. fetchData(params.idTodo!) or you can provide a fallback, e.g. fetchData(params.idTodo ?? ""). Perhaps the fallback can be some default query parameter value for the data fetching.

    const todoLoader: LoaderFunction = async ({ params }: TodoLoaderArgs) => {
      return await fetchData(params.idTodo ?? "");
    };
    

    Edit react-router-6-how-to-strongly-type-the-params-option-in-route-loader (forked)

    Secondary Approach

    It's a bit of a roundabout method, but this appears to be correctly typed and working in a running codesandbox, might be a bit "hackish" though (my Typescript foo is not great). The gist is that you need to override the loader function args parameter so you can override the params property to include the path params you want to access in the loader.

    The loader definitions to override:

    /**
     * The parameters that were parsed from the URL path.
     */
    export declare type Params<Key extends string = string> = {
        readonly [key in Key]: string | undefined;
    };
    
    /**
     * @private
     * Arguments passed to route loader/action functions.  Same for now but we keep
     * this as a private implementation detail in case they diverge in the future.
     */
    interface DataFunctionArgs {
        request: Request;
        params: Params;
        context?: any;
    }
    /**
     * Arguments passed to loader functions
     */
    export interface LoaderFunctionArgs extends DataFunctionArgs {
    }
    /**
     * Route loader function signature
     */
    export interface LoaderFunction {
        (args: LoaderFunctionArgs): Promise<Response> | Response | Promise<any> | any;
    }
    

    New interface declarations and usage:

    import {
      RouterProvider,
      createBrowserRouter,
      Navigate,
      useLoaderData,
      LoaderFunction,
      LoaderFunctionArgs
    } from "react-router-dom";
    
    interface TodoLoaderFunctionArgs extends Omit<LoaderFunctionArgs, "params"> {
      params: {
        todoId: string;
      };
    }
    
    interface TodoLoaderFunction extends Omit<LoaderFunction, "args"> {
      (args: TodoLoaderFunctionArgs):
        | Promise<Response>
        | Response
        | Promise<any>
        | any;
    }
    
    const todoLoader: TodoLoaderFunction = async ({ params }) => {
      return await fetchData(params.todoId);
    };
    
    const router = createBrowserRouter([
      {
        path: "/todos/:todoId",
        element: <Todo />,
        loader: todoLoader as LoaderFunction
      },
    ]);
    

    enter image description here

    Edit react-router-6-how-to-strongly-type-the-params-option-in-route-loader

    Secondary Approach #2

    An alternative, which might be a little simpler, would be to simply recast the params prop.

    interface Params {
      todoId: string
    }
    
    const todoLoader: LoaderFunction = async ({ params }) => {
      const typedParams = params as unknown as Params;
      return await fetchData(typedParams.todoId);
    };
    

    enter image description here