From 6db6d2c4c67c122a8d39a2de2f8b8c68c8c7c9b3 Mon Sep 17 00:00:00 2001 From: leesb971204 Date: Tue, 21 Jul 2026 15:04:15 +0900 Subject: [PATCH] fix: update error type to unknown Signed-off-by: leesb971204 --- .changeset/error-component-props-unknown.md | 30 +++++++++++++++++++ .../api/router/errorComponentComponent.md | 15 +++++++++- docs/router/guide/data-loading.md | 10 +++---- docs/router/guide/external-data-loading.md | 2 +- docs/router/how-to/setup-testing.md | 16 +++++++--- .../how-to/use-environment-variables.md | 8 +++-- docs/router/how-to/validate-search-params.md | 4 +-- .../react/guide/server-components.md | 6 +++- packages/react-router/src/Match.tsx | 4 +-- .../tests/errorComponent.test-d.tsx | 21 +++++++++++++ .../tests/errorComponent.test.tsx | 11 +++++-- ...-7638-invalidate-transition-error.test.tsx | 5 +++- packages/react-router/tests/lazy/error.tsx | 6 +++- packages/react-router/tests/loaders.test.tsx | 5 +++- packages/react-router/tests/router.test.tsx | 8 +++-- packages/router-core/src/route.ts | 4 +-- .../tests/errorComponent.test-d.tsx | 21 +++++++++++++ .../tests/errorComponent.test.tsx | 5 +++- packages/solid-router/tests/link.test.tsx | 8 +++-- packages/solid-router/tests/router.test.tsx | 8 +++-- .../tests/server/errorComponent.test.tsx | 4 ++- packages/vue-router/src/CatchBoundary.tsx | 8 ++--- packages/vue-router/src/Matches.tsx | 7 ++++- .../tests/errorComponent.test-d.tsx | 21 +++++++++++++ .../vue-router/tests/errorComponent.test.tsx | 5 +++- packages/vue-router/tests/link.test.tsx | 8 +++-- packages/vue-router/tests/router.test.tsx | 8 +++-- 27 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 .changeset/error-component-props-unknown.md create mode 100644 packages/react-router/tests/errorComponent.test-d.tsx create mode 100644 packages/solid-router/tests/errorComponent.test-d.tsx create mode 100644 packages/vue-router/tests/errorComponent.test-d.tsx diff --git a/.changeset/error-component-props-unknown.md b/.changeset/error-component-props-unknown.md new file mode 100644 index 00000000000..687fe784697 --- /dev/null +++ b/.changeset/error-component-props-unknown.md @@ -0,0 +1,30 @@ +--- +'@tanstack/router-core': minor +'@tanstack/react-router': minor +'@tanstack/solid-router': minor +'@tanstack/vue-router': minor +--- + +Type the `error` prop passed to error components as `unknown` instead of `Error`. + +Route loading and rendering code can throw any value — a primitive, a rich +domain object, or an `Error` — and the router already modelled `RouteMatch.error` +as `unknown`. Only the error-component contract claimed `Error`, which forced an +`as any` cast internally and misled consumers into unguarded `error.message` +access that could fail at runtime. + +`ErrorComponentProps` no longer takes a `TError` type parameter. The parameter +was introduced in a previous release but never propagated through route +configuration or the React, Solid, and Vue `ErrorRouteComponent` contracts, so +it could not describe the thrown value. A typed-error API can be revisited if an +explicit error type can be threaded end to end. + +Error components must now narrow before accessing error-specific properties: + +```tsx +errorComponent: ({ error }) => { + const message = error instanceof Error ? error.message : String(error) + + return
{message}
+} +``` diff --git a/docs/router/api/router/errorComponentComponent.md b/docs/router/api/router/errorComponentComponent.md index 04399f124da..f13f330c2af 100644 --- a/docs/router/api/router/errorComponentComponent.md +++ b/docs/router/api/router/errorComponentComponent.md @@ -11,9 +11,22 @@ The `ErrorComponent` component accepts the following props: ### `props.error` prop -- Type: `TError` (defaults to `Error`) +- Type: `unknown` - The error that was thrown by the component's children + Anything can be thrown in JavaScript, and route loading and rendering code is + no exception — a `loader` may `throw 'oops'` or throw a rich domain object + instead of an `Error`. The prop is therefore typed as `unknown`, and error + components must narrow the value before accessing error-specific properties: + + ```tsx + errorComponent: ({ error }) => { + const message = error instanceof Error ? error.message : String(error) + + return
{message}
+ } + ``` + ### `props.info` prop - Type: `{ componentStack: string }` diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 2ecdf443add..e705e1f2db7 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -559,7 +559,7 @@ export const Route = createFileRoute('/posts')({ The `routeOptions.errorComponent` option is a component that is rendered when an error occurs during the route loading or rendering lifecycle. It is rendered with the following props: -- `error` - The error that occurred +- `error` - The error that occurred, typed as `unknown`. Anything can be thrown, so narrow it before accessing error-specific properties. - `reset` - A function to reset the internal `CatchBoundary` ```tsx @@ -567,8 +567,8 @@ The `routeOptions.errorComponent` option is a component that is rendered when an export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), errorComponent: ({ error }) => { - // Render an error message - return
{error.message}
+ // `error` is `unknown`, so narrow it before reading `message` + return
{error instanceof Error ? error.message : String(error)}
}, }) ``` @@ -582,7 +582,7 @@ export const Route = createFileRoute('/posts')({ errorComponent: ({ error, reset }) => { return (
- {error.message} + {error instanceof Error ? error.message : String(error)} diff --git a/docs/start/framework/react/guide/server-components.md b/docs/start/framework/react/guide/server-components.md index 893a6333394..829e78a3c76 100644 --- a/docs/start/framework/react/guide/server-components.md +++ b/docs/start/framework/react/guide/server-components.md @@ -943,7 +943,11 @@ export const Route = createFileRoute('/')({ // If this fails, the errorComponent renders Greeting: await getGreeting(), }), - errorComponent: ({ error }) =>
Failed to load: {error.message}
, + errorComponent: ({ error }) => ( +
+ Failed to load: {error instanceof Error ? error.message : String(error)} +
+ ), component: HomePage, }) ``` diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5de5546aeb8..544a5c3823f 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -378,7 +378,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ ErrorComponent return ( { + expectTypeOf().toBeUnknown() +}) + +test('error-specific properties are accessible after narrowing', () => { + const narrowed = (props: ErrorComponentProps) => + props.error instanceof Error ? props.error.message : String(props.error) + + expectTypeOf(narrowed).returns.toEqualTypeOf() +}) + +test('error-specific properties are inaccessible without narrowing', () => { + const unnarrowed = (props: ErrorComponentProps) => + // @ts-expect-error - `error` is `unknown`; narrow it before accessing `message` + props.error.message + + expectTypeOf(unnarrowed).toBeFunction() +}) diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 296c0caf348..0d19aa839be 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -23,7 +23,10 @@ import { import type { ErrorComponentProps, RouterHistory } from '../src' function MyErrorComponent(props: ErrorComponentProps) { - return
Error: {props.error.message}
+ const message = + props.error instanceof Error ? props.error.message : String(props.error) + + return
Error: {message}
} async function asyncToThrowFn() { @@ -380,7 +383,11 @@ test('#4684: SSR renders head content when beforeLoad throws', async () => { component: function FailingRoute() { return
Route content
}, - errorComponent: ({ error }) =>
Error UI: {error.message}
, + errorComponent: ({ error }) => ( +
+ Error UI: {error instanceof Error ? error.message : String(error)} +
+ ), }) const handler = createRequestHandler({ diff --git a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx index 9f1c0eec3f5..08cc51dd1d1 100644 --- a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx +++ b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx @@ -95,7 +95,10 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) { history: createMemoryHistory({ initialEntries: ['/test'] }), defaultErrorComponent: (props: ErrorComponentProps) => { errorRenders++ - return
error: {props.error.message}
+ const message = + props.error instanceof Error ? props.error.message : String(props.error) + + return
error: {message}
}, }) diff --git a/packages/react-router/tests/lazy/error.tsx b/packages/react-router/tests/lazy/error.tsx index 6ef7036e33f..271f6a058ec 100644 --- a/packages/react-router/tests/lazy/error.tsx +++ b/packages/react-router/tests/lazy/error.tsx @@ -3,6 +3,10 @@ import { createLazyRoute } from '../../src' export function Route(id: string) { return createLazyRoute(id)({ component: () =>
About route content
, - errorComponent: ({ error }) =>
Lazy Error: {error.message}
, + errorComponent: ({ error }) => ( +
+ Lazy Error: {error instanceof Error ? error.message : String(error)} +
+ ), }) } diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index 869aeec76c3..b05f5bb7e05 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -895,9 +895,12 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul }, errorComponent: ({ error }) => { errorComponentRenderCount(error) + const message = error instanceof Error ? error.message : '' + const name = error instanceof Error ? error.name : '' + return (
- Error Component: {error.message} | Name: {error.name} + Error Component: {message} | Name: {name}
) }, diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index cf3b1c11feb..77f686ca411 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1853,7 +1853,7 @@ describe('search params in URL', () => { describe.each(testCases)('search param validation', (validateSearch) => { it('does not throw an error when the search param is valid', async () => { - let errorSpy: Error | undefined + let errorSpy: unknown const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1873,7 +1873,7 @@ describe('search params in URL', () => { }) it('throws an error when the search param is not valid', async () => { - let errorSpy: Error | undefined + let errorSpy: unknown const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1888,7 +1888,9 @@ describe('search params in URL', () => { await act(() => router.load()) expect(errorSpy).toBeInstanceOf(SearchParamError) - expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + expect((errorSpy as SearchParamError).cause).toBeInstanceOf( + TestValidationError, + ) }) }) }) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 2de7dc57f05..d0c352b9fd2 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1607,8 +1607,8 @@ export type ErrorRouteProps = { reset: () => void } -export type ErrorComponentProps = { - error: TError +export type ErrorComponentProps = { + error: unknown info?: { componentStack: string } reset: () => void } diff --git a/packages/solid-router/tests/errorComponent.test-d.tsx b/packages/solid-router/tests/errorComponent.test-d.tsx new file mode 100644 index 00000000000..88572bbeee6 --- /dev/null +++ b/packages/solid-router/tests/errorComponent.test-d.tsx @@ -0,0 +1,21 @@ +import { expectTypeOf, test } from 'vitest' +import type { ErrorComponentProps } from '../src' + +test('error prop is unknown', () => { + expectTypeOf().toBeUnknown() +}) + +test('error-specific properties are accessible after narrowing', () => { + const narrowed = (props: ErrorComponentProps) => + props.error instanceof Error ? props.error.message : String(props.error) + + expectTypeOf(narrowed).returns.toEqualTypeOf() +}) + +test('error-specific properties are inaccessible without narrowing', () => { + const unnarrowed = (props: ErrorComponentProps) => + // @ts-expect-error - `error` is `unknown`; narrow it before accessing `message` + props.error.message + + expectTypeOf(unnarrowed).toBeFunction() +}) diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index 4ee1abf3f1b..4a14a58e1aa 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -12,7 +12,10 @@ import { import type { ErrorComponentProps } from '../src' function MyErrorComponent(props: ErrorComponentProps) { - return
Error: {props.error.message}
+ const message = () => + props.error instanceof Error ? props.error.message : String(props.error) + + return
Error: {message()}
} async function asyncToThrowFn() { diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index d0fc7ae4697..a65bc55f5f3 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -3660,7 +3660,9 @@ describe('Link', () => { test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { const rootRoute = createRootRoute({ - errorComponent: (err) =>
{err.error.message}
, + errorComponent: (err) => ( +
{err.error instanceof Error ? err.error.message : ''}
+ ), }) const indexRoute = createRoute({ @@ -5629,7 +5631,9 @@ describe('search middleware', () => { test('search middlewares work', async () => { const rootRoute = createRootRoute({ - errorComponent: (error) =>
{error.error.stack}
, + errorComponent: (error) => ( +
{error.error instanceof Error ? error.error.stack : ''}
+ ), validateSearch: (input) => { return { root: input.root as string | undefined, diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index 1ee017937a7..f6c07927357 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1380,7 +1380,7 @@ describe('search params in URL', () => { describe.each(testCases)('search param validation', (validateSearch) => { it('does not throw an error when the search param is valid', async () => { - let errorSpy: Error | undefined + let errorSpy: unknown const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1400,7 +1400,7 @@ describe('search params in URL', () => { }) it('throws an error when the search param is not valid', async () => { - let errorSpy: Error | undefined + let errorSpy: unknown const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1415,7 +1415,9 @@ describe('search params in URL', () => { await router.load() expect(errorSpy).toBeInstanceOf(SearchParamError) - expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + expect((errorSpy as SearchParamError).cause).toBeInstanceOf( + TestValidationError, + ) }) }) }) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index f342aaae0d9..7d0ba5f649d 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -20,7 +20,9 @@ describe('errorComponent (server)', () => { }, component: () =>
Index route
, errorComponent: ({ error }) => ( -
Route error: {error.message}
+
+ Route error: {error instanceof Error ? error.message : String(error)} +
), }) diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx index 5d75de8d6d1..d5b6fe639d7 100644 --- a/packages/vue-router/src/CatchBoundary.tsx +++ b/packages/vue-router/src/CatchBoundary.tsx @@ -1,11 +1,7 @@ import * as Vue from 'vue' +import type { ErrorComponentProps } from '@tanstack/router-core' import type { ErrorRouteComponent } from './route' -interface ErrorComponentProps { - error: Error - reset: () => void -} - type CatchBoundaryProps = { getResetKey: () => number | string children: Vue.VNode @@ -109,7 +105,7 @@ export function CatchBoundary(props: CatchBoundaryProps) { export const ErrorComponent = Vue.defineComponent({ name: 'ErrorComponent', props: { - error: Object, + error: null, reset: Function, }, setup(props) { diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index da8220db293..58646a13e74 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -89,7 +89,12 @@ const errorComponentFn: ErrorRouteComponentType = ( ) => { return Vue.h('div', { class: 'error' }, [ Vue.h('h1', null, 'Error'), - Vue.h('p', null, props.error.message || String(props.error)), + Vue.h( + 'p', + null, + (props.error instanceof Error ? props.error.message : '') || + String(props.error), + ), Vue.h('button', { onClick: props.reset }, 'Try Again'), ]) } diff --git a/packages/vue-router/tests/errorComponent.test-d.tsx b/packages/vue-router/tests/errorComponent.test-d.tsx new file mode 100644 index 00000000000..88572bbeee6 --- /dev/null +++ b/packages/vue-router/tests/errorComponent.test-d.tsx @@ -0,0 +1,21 @@ +import { expectTypeOf, test } from 'vitest' +import type { ErrorComponentProps } from '../src' + +test('error prop is unknown', () => { + expectTypeOf().toBeUnknown() +}) + +test('error-specific properties are accessible after narrowing', () => { + const narrowed = (props: ErrorComponentProps) => + props.error instanceof Error ? props.error.message : String(props.error) + + expectTypeOf(narrowed).returns.toEqualTypeOf() +}) + +test('error-specific properties are inaccessible without narrowing', () => { + const unnarrowed = (props: ErrorComponentProps) => + // @ts-expect-error - `error` is `unknown`; narrow it before accessing `message` + props.error.message + + expectTypeOf(unnarrowed).toBeFunction() +}) diff --git a/packages/vue-router/tests/errorComponent.test.tsx b/packages/vue-router/tests/errorComponent.test.tsx index f606040f173..a59fcb34ee7 100644 --- a/packages/vue-router/tests/errorComponent.test.tsx +++ b/packages/vue-router/tests/errorComponent.test.tsx @@ -12,7 +12,10 @@ import { import type { ErrorComponentProps } from '../src' function MyErrorComponent(props: ErrorComponentProps) { - return
Error: {props.error.message}
+ const message = + props.error instanceof Error ? props.error.message : String(props.error) + + return
Error: {message}
} async function asyncToThrowFn() { diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index e047fe59933..d288b153151 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -3733,7 +3733,9 @@ describe('Link', () => { test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { const rootRoute = createRootRoute({ - errorComponent: (err) =>
{err.error.message}
, + errorComponent: (err) => ( +
{err.error instanceof Error ? err.error.message : ''}
+ ), }) const indexRoute = createRoute({ @@ -5800,7 +5802,9 @@ describe('search middleware', () => { test('search middlewares work', async () => { const rootRoute = createRootRoute({ - errorComponent: (error) =>
{error.error.stack}
, + errorComponent: (error) => ( +
{error.error instanceof Error ? error.error.stack : ''}
+ ), validateSearch: (input) => { return { root: input.root as string | undefined, diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index 44a7acd43c9..b94f1e1169f 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -1382,7 +1382,7 @@ describe('search params in URL', () => { describe.each(testCases)('search param validation', (validateSearch) => { it('does not throw an error when the search param is valid', async () => { - let errorSpy: Error | undefined + let errorSpy: unknown const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1402,7 +1402,7 @@ describe('search params in URL', () => { }) it('throws an error when the search param is not valid', async () => { - let errorSpy: Error | undefined + let errorSpy: unknown const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1417,7 +1417,9 @@ describe('search params in URL', () => { await router.load() expect(errorSpy).toBeInstanceOf(SearchParamError) - expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + expect((errorSpy as SearchParamError).cause).toBeInstanceOf( + TestValidationError, + ) }) }) })