Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/docs/src/pages/ApiTypesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,62 @@ type Data = ExtractRouteData<typeof userRoute>;
// { name: string; age: number }`}</CodeBlock>
</article>

<article className="api-item">
<h3>
<code>RouteComponentPropsOf&lt;T&gt;</code>
</h3>
<p>
Utility type that extracts the component props type from a route
definition. Returns <code>RouteComponentProps</code> for routes
without a loader, or <code>RouteComponentPropsWithData</code> for
routes with a loader. This is useful for typing route components
separately from the route definition.
</p>
<CodeBlock language="tsx">{`import { route, routeState } from "@funstack/router";
import type { RouteComponentPropsOf } from "@funstack/router";

// Route without loader
const userRoute = route({
id: "user",
path: "/users/:userId",
component: UserPage,
});

type UserPageProps = RouteComponentPropsOf<typeof userRoute>;
// RouteComponentProps<{ userId: string }, undefined>

function UserPage({ params }: UserPageProps) {
return <h1>User: {params.userId}</h1>;
}

// Route with loader
const profileRoute = route({
id: "profile",
path: "/profile/:userId",
loader: () => ({ name: "John", age: 30 }),
component: ProfilePage,
});

type ProfilePageProps = RouteComponentPropsOf<typeof profileRoute>;
// RouteComponentPropsWithData<{ userId: string }, { name: string; age: number }, undefined>

// Route with state
type MyState = { tab: string };
const settingsRoute = routeState<MyState>()({
id: "settings",
path: "/settings",
component: SettingsPage,
});

type SettingsPageProps = RouteComponentPropsOf<typeof settingsRoute>;
// RouteComponentProps<Record<string, never>, MyState>`}</CodeBlock>
<p>
<strong>Note:</strong> This utility requires a route with an{" "}
<code>id</code> property. Using it with a route without{" "}
<code>id</code> will result in a type error.
</p>
</article>

<article className="api-item">
<h3>
<code>RouteDefinition</code>
Expand Down
56 changes: 54 additions & 2 deletions packages/docs/src/pages/LearnTypeSafetyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,52 @@ function UserPostsPage() {
FUNSTACK Router exports several utility types for extracting
information from route definitions:
</p>

<h4>RouteComponentPropsOf</h4>
<p>
The most useful utility for typing route components is{" "}
<code>RouteComponentPropsOf</code>. It extracts the complete props
type from a route definition, so you don't have to manually construct{" "}
<code>RouteComponentProps</code> or{" "}
<code>RouteComponentPropsWithData</code> types.
</p>
<CodeBlock language="tsx">{`import { route, routeState } from "@funstack/router";
import type { RouteComponentPropsOf } from "@funstack/router";

// Define the route
const userRoute = route({
id: "user",
path: "/users/:userId",
loader: async ({ params }) => {
const response = await fetch(\`/api/users/\${params.userId}\`);
return response.json() as Promise<User>;
},
component: UserPage,
});

// Extract props type directly from the route
type UserPageProps = RouteComponentPropsOf<typeof userRoute>;

// Now use it in your component
function UserPage({ params, data }: UserPageProps) {
const user = use(data);
return <h1>User: {user.name} (ID: {params.userId})</h1>;
}`}</CodeBlock>
<p>
This approach keeps your component props in sync with the route
definition automatically. If you change the route's path or loader,
the props type updates accordingly.
</p>
<p>
<strong>Note:</strong> <code>RouteComponentPropsOf</code> requires the
route to have an <code>id</code> property. Using it with a route
without <code>id</code> will result in a type error.
</p>

<h4>Other Extraction Utilities</h4>
<p>
For more granular type extraction, use these individual utilities:
</p>
<CodeBlock language="tsx">{`import type {
ExtractRouteId,
ExtractRouteParams,
Expand Down Expand Up @@ -543,9 +589,15 @@ type Data = ExtractRouteData<typeof myRoute>;
<code>:userId</code>
</li>
<li>
Use <code>RouteComponentProps&lt;Params, State&gt;</code> or{" "}
Use <code>RouteComponentPropsOf&lt;typeof route&gt;</code> to
extract the props type directly from a route definition (requires{" "}
<code>id</code>)
</li>
<li>
Alternatively, use{" "}
<code>RouteComponentProps&lt;Params, State&gt;</code> or{" "}
<code>RouteComponentPropsWithData&lt;Params, Data, State&gt;</code>{" "}
for typed component props
for manual type construction
</li>
<li>
Loader data is delivered as a Promise&mdash;use React's{" "}
Expand Down
108 changes: 108 additions & 0 deletions packages/router/src/__tests__/route.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type {
ExtractRouteParams,
ExtractRouteState,
ExtractRouteData,
RouteComponentPropsOf,
RouteComponentProps,
RouteComponentPropsWithData,
} from "../route.js";
import { useRouteParams } from "../hooks/useRouteParams.js";
import { useRouteState } from "../hooks/useRouteState.js";
Expand Down Expand Up @@ -288,3 +291,108 @@ describe("pathless route type inference", () => {
expectTypeOf(params).toEqualTypeOf<Record<string, never>>();
});
});

describe("RouteComponentPropsOf utility type", () => {
it("extracts RouteComponentProps for route without loader", () => {
const userRoute = route({
id: "user",
path: "/users/:userId",
component: () => null,
});

type Props = RouteComponentPropsOf<typeof userRoute>;
expectTypeOf<Props>().toEqualTypeOf<
RouteComponentProps<{ userId: string }, undefined>
>();
});

it("extracts RouteComponentPropsWithData for route with loader", () => {
const userRoute = route({
id: "user",
path: "/users/:userId",
loader: () => ({ name: "John", age: 30 }),
component: () => null,
});

type Props = RouteComponentPropsOf<typeof userRoute>;
expectTypeOf<Props>().toEqualTypeOf<
RouteComponentPropsWithData<
{ userId: string },
{ name: string; age: number },
undefined
>
>();
});

it("extracts props with state type from routeState", () => {
type MyState = { scrollPos: number };
const scrollRoute = routeState<MyState>()({
id: "scroll",
path: "/scroll",
component: () => null,
});

type Props = RouteComponentPropsOf<typeof scrollRoute>;
expectTypeOf<Props>().toEqualTypeOf<
RouteComponentProps<Record<string, never>, MyState>
>();
});

it("extracts props with both state and loader", () => {
type FilterState = { filter: string };
const productsRoute = routeState<FilterState>()({
id: "products",
path: "/products/:category",
loader: () => ({ items: [] as string[] }),
component: () => null,
});

type Props = RouteComponentPropsOf<typeof productsRoute>;
expectTypeOf<Props>().toEqualTypeOf<
RouteComponentPropsWithData<
{ category: string },
{ items: string[] },
FilterState
>
>();
});

it("extracts props for pathless route", () => {
const layoutRoute = route({
id: "layout",
component: () => null,
});

type Props = RouteComponentPropsOf<typeof layoutRoute>;
expectTypeOf<Props>().toEqualTypeOf<
RouteComponentProps<Record<string, never>, undefined>
>();
});

it("extracts props for pathless route with loader", () => {
const layoutRoute = route({
id: "layout",
loader: () => ({ theme: "dark" }),
component: () => null,
});

type Props = RouteComponentPropsOf<typeof layoutRoute>;
expectTypeOf<Props>().toEqualTypeOf<
RouteComponentPropsWithData<
Record<string, never>,
{ theme: string },
undefined
>
>();
});

it("rejects route without id with type error", () => {
const noIdRoute = route({
path: "/users/:userId",
component: () => null,
});

// @ts-expect-error - RouteComponentPropsOf requires a route with id
type _Props = RouteComponentPropsOf<typeof noIdRoute>;
});
});
1 change: 1 addition & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ export type {
ExtractRouteParams,
ExtractRouteState,
ExtractRouteData,
RouteComponentPropsOf,
} from "./route.js";
22 changes: 21 additions & 1 deletion packages/router/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export type RouteComponentPropsWithData<
* Route definition created by the `route` helper function.
*/
export interface OpaqueRouteDefinition {
[routeDefinitionSymbol]: never;
[routeDefinitionSymbol]: unknown;
path?: string;
children?: RouteDefinition[];
exact?: boolean;
Expand Down Expand Up @@ -147,6 +147,26 @@ export type ExtractRouteData<T> =
? Data
: never;

/** Extract the component props type from a TypefulOpaqueRouteDefinition */
export type RouteComponentPropsOf<
T extends TypefulOpaqueRouteDefinition<
string,
Record<string, string>,
unknown,
unknown
>,
> =
T extends TypefulOpaqueRouteDefinition<
infer _Id,
infer Params,
infer State,
infer Data
>
? Data extends undefined
? RouteComponentProps<Params, State>
: RouteComponentPropsWithData<Params, Data, State>
: never;

/**
* Any route definition defined by user.
*/
Expand Down