@void-snippets/react

useTypedParams

Read URL path parameters typed to the route's :segments — no manual casting.

#What it does

useTypedParams reads URL path parameters (the :segments in your route path) and types them directly from the route's path string. TypeScript extracts the param names automatically — no manual type annotations, no as string casts scattered across components.

This is the path-param counterpart to useTypedSearchParams:

HookReads fromExample
useTypedParamsURL path — /users/:userId{ userId: string }
useTypedSearchParamsQuery string — ?page=1&sort=asc{ page: string, sort: string }

#The problem it solves

typescript
// ❌ Without it — useParams returns Record<string, string | undefined>
// TypeScript has no idea what params exist
const params = useParams();
const conversationId = params.conversationId; // string | undefined — no path context
const id = stringToId<Conversation.Id>(conversationId!); // have to assert !
typescript
// ✅ With useTypedParams — param names inferred from the path literal
const { conversationId } = useTypedParams(AppRoutes.inbox.conversation);
// TypeScript inferred { conversationId: string } from '/inbox/conversations/:conversationId'
// No ! needed — the params are guaranteed by the router matching the route
const id = stringToId<Conversation.Id>(conversationId);

#Signature

typescript
import { useTypedParams } from '@void-snippets/react';

function useTypedParams<P extends string, S>(
  route: ProcessedRoute<P, S>
): StringifiedRouteParams<P>

Where StringifiedRouteParams

maps every path parameter to string:

typescript
// Path: '/inbox/conversations/:conversationId'
// StringifiedRouteParams<...> resolves to:
{ conversationId: string }

// Path: '/orgs/:orgId/projects/:projectId'
// StringifiedRouteParams<...> resolves to:
{ orgId: string; projectId: string }

// Path: '/files/:path?'  (optional segment)
// StringifiedRouteParams<...> resolves to:
{ path?: string }

#Parameters

ParameterTypeDescription
routeProcessedRouteA leaf from createRouteContract. Used only for TypeScript inference — the value is never read at runtime.

The _route parameter is erased at runtime. You are not passing configuration — you are giving TypeScript a reference to infer the path string P and extract its parameter names.

#Return value

An object with one string property per named path segment. All values are string at runtime — React Router's useParams() always returns strings, regardless of how build() typed them as string | number.

#Working with branded IDs

Because all params are plain strings, the idiomatic pattern is to cast to a branded ID immediately after reading:

typescript
import { useTypedParams } from '@void-snippets/react';
import { stringToId } from '@void-snippets/core';
import type { Conversation } from './conversation.types';
import { AppRoutes } from '@/routes';

function ConversationPage() {
  const { conversationId } = useTypedParams(AppRoutes.inbox.conversation);
  // conversationId is string — safe to pass to stringToId
  const id = stringToId<Conversation.Id>(conversationId);

  const { item: conversation, isLoading } = conversationHooks.useGet(id);
  // ...
}

#Full example — multiple params

typescript
// routes.ts
export const AppRoutes = createRouteContract({
  orgs: {
    project: defineRoute('/orgs/:orgId/projects/:projectId', {
      breadcrumb: 'Project',
      permissions: ['ORG_MEMBER'],
    }).search<{ tab?: 'overview' | 'tasks' | 'settings' }>(),
  },
});
tsx
// ProjectPage.tsx
function ProjectPage() {
  // Path params from the URL
  const { orgId, projectId } = useTypedParams(AppRoutes.orgs.project);
  // TypeScript knows: { orgId: string; projectId: string }
  // No useParams(), no non-null assertions, no Record<string, string | undefined>

  // Search params from the query string
  const { search, setSearch } = useTypedSearchParams(AppRoutes.orgs.project);
  // TypeScript knows: { tab?: 'overview' | 'tasks' | 'settings' }

  const id = stringToId<Project.Id>(projectId);
  const { item: project, isLoading } = projectHooks.useGet(id);

  if (isLoading || !project) return <Skeleton />;

  return (
    <div>
      <h1>{project.name}</h1>
      <nav>
        {(['overview', 'tasks', 'settings'] as const).map(tab => (
          <button
            key={tab}
            onClick={() => setSearch({ tab })}
            aria-current={search.tab === tab || (!search.tab && tab === 'overview')}
          >
            {tab}
          </button>
        ))}
      </nav>
      {/* render the active tab */}
    </div>
  );
}

#How it works internally

useTypedParams is a thin typed wrapper around React Router's useParams():

typescript
export function useTypedParams<P extends string, S>(
  _route: ProcessedRoute<P, S>,        // type-only — never read
): StringifiedRouteParams<P> {
  const params = useParams();          // Readonly<Record<string, string | undefined>>
  return params as unknown as StringifiedRouteParams<P>;
  // Cast is safe: the router only activates this component when the path
  // matches, so every declared segment is guaranteed to be present.
}

The safety of the cast relies on the router configuration using the same path string you defined in createRouteContract. Since both the router and useTypedParams reference AppRoutes, they are structurally linked.