Error handling
- Category: Core
Every error in Point0 is a typed instance, never unknown. By default that's
ErrorPoint0, and you can throw it
as-is. You can — but don't have to — swap it for any error class of the same
or a wider structure via .errorClass: a constructor with
the (message?, options?) shape plus the three statics from /
serializePublic / serializePrivate. Whatever class you pick threads through
the whole app: a query's .error, the .on('error') callback, the error a
.with may return, and the props the error component renders. Across
the wire it's serialized for the audience — public for an untrusted client in
production, full for the developer in development — and the server stack never
reaches the browser, even though the first page load is server-rendered.
The page below describes that class — its fields, how it serializes, and how a
throw reaches your error component. One convenient way to build such a class
is error0; it's only an example implementation,
covered at the end. You could equally
hand-write a class extends Error.
This is the full error class start0 wires onto its root — typical of a real app, plugins and all:
// src/lib/error.ts — built with error0 (one option; see below)
import { Error0 } from '@1gr14/error0'
import { causePlugin } from '@1gr14/error0/plugins/cause'
import { codeStatusPlugin } from '@1gr14/error0/plugins/code-status'
import { expectedPlugin } from '@1gr14/error0/plugins/expected'
import { metaPlugin } from '@1gr14/error0/plugins/meta'
import { redirectPlugin } from '@1gr14/error0/plugins/point0-redirect'
import { responsePlugin } from '@1gr14/error0/plugins/response'
import { stackPlugin } from '@1gr14/error0/plugins/stack'
export const AppError = Error0.mark('AppError')
.use(
codeStatusPlugin({
codes: { UNAUTHORIZED: 401, FORBIDDEN: 403, UNSUBSCRIBED: 403 },
transport: 'public', // these codes survive the public wire in production
}),
)
.use(metaPlugin()) // structured, private-by-default context
.use(causePlugin()) // keep the cause chain for the private projection
.use(responsePlugin()) // let an error carry a ready-made Response
.use(redirectPlugin()) // bridge a `cause: RedirectTask` into `error.redirect`
.use(expectedPlugin({ transport: 'public' })) // a public flag your reporter reads
.use(stackPlugin()) // stack in the private projection only
export type AppError = InstanceType<typeof AppError>// src/lib/root.tsx — register it once on the root
export const root = Point0.lets.root().errorClass(AppError).root()// anywhere a controllable, serializable error is wanted:
throw new AppError('Sign in to continue', { code: 'UNAUTHORIZED' }) // => HTTP 401
throw new AppError('Not found', { status: 404, meta: { id } })The thrown AppError is what the client receives, what query.error is typed
as, and what the error component renders. The rest of this page shows where each
piece comes from — first the class contract itself, then error0 as one way to
satisfy it.
The default error class: ErrorPoint0
If you never call .errorClass, Point0 uses ErrorPoint0. It
extends Error and adds optional fields the framework reads:
import { ErrorPoint0 } from '@point0/core'
throw new ErrorPoint0('Failed', {
status: 500, // drives the HTTP status of the response (and SSR)
code: 'SOMETHING_BROKE', // a stable string code you branch on
meta: { attemptId }, // structured context for logs (never sent to the client)
})The constructor copies each option onto the instance only when truthy — so
status: 0 or an empty code won't be set, and an empty message falls back to
'Unknown error':
new ErrorPoint0().message // => 'Unknown error'
new ErrorPoint0('x', { status: 0 }).status // => undefined (0 is falsy)All fields are optional: status, code, redirect, response, headers,
meta. You rarely construct ErrorPoint0 by hand — you replace it with your
own class (below) and throw that. But it's the type behind every
framework-raised error (a 404 on an unmatched route, a redirect carrier) when
you haven't set one.
Both fields are wired in the engine. When error.response is set, it's used
verbatim as the emitted Response; otherwise the engine builds a JSON error
response from status. When error.headers is set, it's merged into the
response headers through the effects system.
Replacing the class: .errorClass(AppError)
.errorClass is a root-only setting. It swaps the default and retypes the
entire chain — every downstream typed-error slot becomes your class:
export const root = Point0.lets.root().errorClass(AppError).root()Your class doesn't have to be an error0 class. The contract is structural: a
constructor with the (message?, options?) shape plus three statics — from,
serializePublic, serializePrivate. A plain class extends Error that
satisfies that works; error0 just gives it to you for free. A custom class is
optional — there's always the ErrorPoint0 default.
// either of these is valid as the app error class:
const AppError = Error0.mark('AppError').use(/* plugins */) // built with error0
class AppError extends Error {
/* …implements from/serializePublic/serializePrivate */
}Once set, the engine constructs every framework error through your class
(new AppError(...), AppError.from(...)), so it's the wire and render type
end to end. See .errorClass for the setter itself.
Building AppError with error0
error0 is a separate @1gr14 library for composing
a serializable error class from plugins. It's one way to satisfy the
contract above, not a requirement — Point0 only cares that the resulting class
matches the structure; the plugin API belongs to error0. Two real shapes from
the codebase:
The examples shape — status from a plugin, stack stripped in production:
// abridged from examples/basic/src/lib/error.ts (other plugins omitted)
import { statusPlugin } from '@1gr14/error0/plugins/status'
import { codePlugin } from '@1gr14/error0/plugins/code'
import { metaPlugin } from '@1gr14/error0/plugins/meta'
import { env } from '@point0/core'
export const AppError = Error0.use(statusPlugin())
.use(codePlugin())
.use(metaPlugin())
.use('stack', {
// only keep the stack off-production
serialize: ({ value }) => (env.mode.is.production ? undefined : value),
})
export type AppError = InstanceType<typeof AppError>The production shape (start0) — a code → status map, so a code implies a
status:
// abridged from start0's src/lib/error.ts (cause/response/redirect/expected and
// other plugins omitted)
export const AppError = Error0.mark('AppError')
.use(
codeStatusPlugin({
codes: { UNAUTHORIZED: 401, FORBIDDEN: 403, UNSUBSCRIBED: 403 },
transport: 'public',
}),
)
.use(metaPlugin())
.use(stackPlugin())
export type AppError = InstanceType<typeof AppError>
throw new AppError('Sign in to continue', { code: 'UNAUTHORIZED' }) // => 401transport: 'public' opts a property into the public projection — it survives
the production wire to the client, where it's safe to read, so the client can
branch on it even for an error that arrived from the server. (In start0 that's
how the expected flag — its own plugin, also marked transport: 'public' —
reaches the client to decide whether to report an error to Sentry; the code
above is public for the same reason.) Without it, a property is private and only
appears in logs / development.
The full error0 plugin API — Error0.mark, .use, the option semantics of
codeStatusPlugin / metaPlugin / stackPlugin / the inline
.use('name', { serialize }) form, and transport — is documented on
error0. The shapes above are copied from real
config.
Two audiences: serializePublic vs serializePrivate
An error crosses the wire as one of two projections. The class provides both as statics (and instance methods that delegate to them):
AppError.serializePublic(error)
// => { message, code?, redirect? } ← what an untrusted client may see
AppError.serializePrivate(error)
// => { message, code?, status?, meta?, stack?, redirect?, cause? } ← the operator viewserializePublic never emits a stack, status, meta, or the cause chain
— regardless of environment. serializePrivate is the full picture: status,
stack, meta (JSON-roundtripped; silently dropped if not serializable), and the
whole cause chain.
Neither projection carries the class name. Every error is coerced to one
class (ErrorPoint0, or your .errorClass), so the name is a constant that
identifies nothing; the receiver reconstructs an error from the fields it
recognizes (from() type-checks each one), not from a name. Each link of the
private cause chain still keeps its own native name — there a TypeError vs a
RangeError is real signal for the operator.
The serializer itself never reads the environment — the caller picks the audience by env. The rule everywhere:
- HTTP error responses and the error component branch on the mode:
production → serializePublic, otherwiseserializePrivate. So in development the developer sees the full error (stack and all) right in the browser; in production an untrusted client gets only the safe projection. - Logs are always private, never env-gated. Serialize with
serializePrivate— the operator always needs the stack and cause chain:
// always private, regardless of NODE_ENV
console.error(AppError.serializePrivate(error))If you use the logger exported from @point0/core, you don't even call
serializePrivate yourself: hand it the raw error in the error field and it
serializes privately for you (never toJSON, which is the public projection).
Its LogOptions is { level, category: string[], message, error?, meta? }:
import { logger } from '@point0/core'
console.error({
level: 'error',
category: ['point0'],
message: 'request failed',
error,
})There's also a safety net: ErrorPoint0 defines a non-enumerable toJSON that
returns the public projection. So an accidental JSON.stringify of a
payload that happens to carry an error leaks only { message, code? }, never
the stack or meta:
JSON.stringify({ error: new ErrorPoint0('x', { meta: { secret: 1 } }) })
// => '{"error":{"name":"ErrorPoint0","message":"x"}}' — no meta, no stackFor logs, call serializePrivate explicitly rather than relying on
JSON.stringify (which gives you the public one).
How a thrown error reaches the error component
When a loader, .ctx, or .with throws, Point0
coerces it through AppError.from(error) and routes it to the nearest error
component up the chain — set with .error (and the variant setters
.layoutError / .pageError / .componentError):
export const root = Point0.lets
.root()
.error(({ error }) => <ErrorPageComponent error={error} />)
.componentError(({ error }) => <ErrorComponent error={error} />)
.root()The component receives { type, error }, where error is your typed class and
type is the point variant ('page', 'layout', 'component'). A thrown
error's status flows into the SSR response: the bound error component calls
setStatus(error.status), so throw new AppError('Not found', { status: 404 })
makes the SSR response a real 404.
Which boundary catches it. The error renders at the point that failed —
the point whose loader / .ctx / .with / related query produced it. A failed
layout shows its error in the layout's own slot and its child page never mounts;
a failed page shows its error inside the already-rendered layout. Errors do
not bubble between variants — a component error never "becomes" a page
error.
For that failing point, Point0 picks the error component in this order:
- the nearest
.errordeclared up the chain (an.erroron the point or on a plugin it uses wins over one set higher up — the closest one wins); - otherwise the variant-specific setter matching the failing point's variant —
.pageErrorfor a page,.layoutErrorfor a layout,.componentErrorfor a component (root/base.errorseeds all three at once); - otherwise the built-in default error component.
So a .pageError set on a layout still catches a page below it, and a page's
own .error overrides that inherited .pageError.
GOTCHA: these boundaries catch errors carried in resolved state — a thrown/returned error from a loader,
.ctx,.with, or a related query. They are not React error boundaries: an error thrown while rendering the page component or its.mapperis a plain React render error and is not routed to.error. Surface such failures from a loader/.with(or your own<ErrorBoundary>), not from the render body.
.with is the other path — returning (not throwing) an Error from a
.with renders the error component too, and that's how you build an auth gate
that fires even on a loader-less page:
.with(({ props: { me } }) => {
if (!me) return new AppError('Sign in to continue', { code: 'UNAUTHORIZED' })
return { me }
})When you set no custom error component, Point0 renders a built-in fallback that
shows the serialized error as JSON plus the stack — but only in development.
In production it renders the public projection (no stack) and never falls back
to error.stack, because doing so would bake the server stack into the SSR
HTML the client downloads:
// the framework default error component, in essence:
const json = env.mode.is.production
? AppError.serializePublic(error) // prod: no stack at all
: AppError.serializePrivate(error) // dev: full
// production stack is always undefined here — never error.stackMirror that in your own component: gate the stack on !env.mode.is.production
(and render it client-only). The start0 error component does exactly this — it
branches on error.code / error.status for the user-facing copy and shows the
stack only off-production, inside <ClientOnly>.
Typed error in query.error and .on('error')
Because the class threads through the chain, query.error is your class, never
unknown:
const result = ideaQuery.useQuery({ id })
if (result.error) {
return <div>{result.error.message}</div> // result.error is AppError, fully typed
}The aggregate subscription .on('error', cb) fires on any error event. The
string 'error' is sugar that expands to the four concrete error events —
pointQueryError, pointMutationError, pointInfiniteQueryError,
engineFetchError:
export const root = Point0.lets
.root()
.on('error', ({ side, name, error, meta }) => {
// `error` is the typed instance; `meta` is a log-friendly projection of the payload
console.error({ side, name, error: error.serializePrivate(), ...meta })
})
.root()The callback envelope is { side, name, data, error, meta }. Use error and
meta for logs — meta is the slim, already-serialized projection (points
become ids, requests become { method, path }, errors serialized). Don't log
data: it's the raw payload and not always safe to serialize. Subscribe per
side with .serverOn / .clientOn (server-only events like engineFetchError
are visible only inside .serverOn). Full event surface on Events.
Redirect as an error
A redirect travels as a RedirectTask. You don't throw a dedicated class — you
return or throw the task from a .ctx, .with, or
loader, and Point0 turns it into a real Location response:
import { redirect } from '@/lib/navigation' // from createNavigation(...)
// in a .with gate — return the task, it's treated as a navigation directive
.with(({ props: { me } }) => {
if (!me) return redirect('signIn')
return { me }
})
// in a mutation loader — throw it to short-circuit after the write
.loader(async ({ input }) => {
const idea = await createIdea(input)
throw redirect('ideaView', { id: idea.id })
})The two transports differ by where the navigation happens:
- From an SSR page render, the
RedirectTaskbecomes a realLocationresponse — a genuine HTTP redirect status the browser follows before any of your JS runs. Status is 302 by default; only301 / 302 / 303 / 307 / 308are honored, anything else is clamped to 302. - From a query or mutation, the redirect rides back in the serialized error
as an instruction: the client receives it, then navigates to the new page
itself — no full reload, the client-side (SPA-style) navigation you'd get from
<Link>.
ErrorPoint0 carries a redirect field, and both serializers include
redirect: error.redirect.serialize() when present — that's how the instruction
survives the wire. When you build your class with error0, the
point0-redirect plugin bridges a
cause: RedirectTask into error.redirect for you, so throwing a redirect
through an ordinary error path just works. Full navigation mechanics are on
Navigation.
There is no NotFoundError
Point0 ships no NotFoundError, RedirectError, notFound(), or
redirect()-throwing subclass. You model these states with a code and status
on your error class, not a dedicated subclass:
throw new AppError('Idea not found', { status: 404, meta: { id } })
throw new AppError('Sign in to continue', { code: 'UNAUTHORIZED' }) // => 401 via codeStatusPlugin
throw new AppError('Only the author can edit', { code: 'FORBIDDEN' }) // => 403The status drives the HTTP response and the error component's setStatus; the
code is what your error component branches on. Internally an unmatched route
is just new AppError('Not Found', { status: 404, code: 'POINT0_NOT_FOUND' }) —
same mechanism. Point0 exposes its own codes through POINT0_ERROR_CODES_MAP
(.NOT_FOUND, .REDIRECT, …) for matching framework errors; you can treat
POINT0_NOT_FOUND as expected noise to keep scanner 404s out of your error
reporter.
Security
The browser bundle is public — anyone can read it. Server-only code (loader
bodies, secrets, DB calls) is stripped from it at compile time, so meta and
the private projection never ship to the browser unless your serializer
explicitly marks a property public. (The first page load is still
server-rendered; what's stripped is the server-only code, not the rendered
HTML.) Two rules that follow:
- Keep sensitive context in
meta, notmessage.messageandcodeare public;meta,status, and the stack are private and only appear in logs and development. - Gate auth in
.with, not.ctx..ctxruns only when a point has a loader, so a loader-less page's.ctxnever executes and can't protect anything. A.withthat returns anAppError(or aredirect) runs at render and always fires — see.with.
Reference
ErrorPoint0 / AppError fields
| Field | Type | Notes |
|---|---|---|
message | string | empty → 'Unknown error'. Public. |
status | number? | drives the HTTP/SSR status. Private. 0 is not set. |
code | string? | stable code you branch on. Public. |
redirect | RedirectTask? | carries a redirect; serialized into both projections. |
meta | Record<string, unknown>? | log/dev context. Private. Must be JSON-serializable. |
response | Response? | when set, emitted verbatim as the response (else built from status). |
headers | Record<string, string | undefined>? | when set, merged into the response headers via effects. |
cause | unknown | standard Error cause; included in serializePrivate. |
Static methods (the .errorClass contract)
| Static | Returns | Use |
|---|---|---|
from(error: unknown) | an instance | coerce anything thrown into the class |
serializePublic(error) | { message, code?, redirect? } | untrusted client / production wire — no stack |
serializePrivate(error) | { message, code?, status?, meta?, stack?, redirect?, cause? } | logs / development — full view |
Instance methods error.serializePublic() / error.serializePrivate() delegate
to the statics.
from() coercion notes
- An
ErrorPoint0/AppErroris returned unchanged. - A plain
new Error(...)(constructor exactlyError) is not nested as its owncause— only a subclass instance becomes thecause. message,status,code,stack, andmetaare lifted off the source when present; aRedirectTasksource reconstructsredirectand defaults the message to'Page Redirect'.
Default statuses
| Situation | Status |
|---|---|
| JSON error response | error.status ?? 500 |
| unmatched route | 404 (code: POINT0_NOT_FOUND) |
| redirect | 302 (clamped to 301/302/303/307/308) |
Env gate
Audience selection reads env.mode.is.production (from env, derived
from NODE_ENV). production → serializePublic; otherwise →
serializePrivate. Logs ignore the gate and always use serializePrivate.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️