For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/concepts/typing.md.

End-to-end typing

In Dotpress, you write types once, as schemas and paths, and TypeScript infers the rest. No generic to pass to defineRoute(), no interface to keep in sync with a validator.

Every snippet of this page is compiled against Dotpress when the site is built: hover the code to see what your editor sees.

Path parameters come from the path

const 
const projects: RouteGroup<EmptyObject & {
    orgId: string;
}, unknown>
projects
=
createRouteGroup<"/orgs/:orgId/projects", unknown[]>(prefix: "/orgs/:orgId/projects", middlewares?: readonly Middleware<unknown>[] | undefined): RouteGroup<EmptyObject & {
    orgId: string;
}, unknown>

Creates a nested group, inheriting the prefix and middlewares. Context properties guaranteed by the group middlewares are typed in the handlers of its routes.

createRouteGroup
('/orgs/:orgId/projects')
const projects: RouteGroup<EmptyObject & {
    orgId: string;
}, unknown>
projects
.
RouteGroup<EmptyObject & { orgId: string; }, unknown>.defineRoute<"/:projectId/tasks/:taskId", EmptyObject, false, unknown[]>(route: RouteConfig<"/:projectId/tasks/:taskId", EmptyObject, EmptyObject & {
    orgId: string;
}, false, unknown[], unknown>): void
defineRoute
({
RouteConfig<"/:projectId/tasks/:taskId", EmptyObject, EmptyObject & { orgId: string; }, false, unknown[], unknown>.path: "/:projectId/tasks/:taskId"
path
: '/:projectId/tasks/:taskId',
RouteConfig<"/:projectId/tasks/:taskId", EmptyObject, EmptyObject & { orgId: string; }, false, unknown[], unknown>.handler: RouteHandler<EmptyObject, {
    orgId: string;
    projectId: string;
    taskId: string;
}, false, unknown>
handler
: ({
req: RouteRequest<EmptyObject, {
    orgId: string;
    projectId: string;
    taskId: string;
}, false>
req
}) =>
req: RouteRequest<EmptyObject, {
    orgId: string;
    projectId: string;
    taskId: string;
}, false>
req
.
DotpressRequest<{ orgId: string; projectId: string; taskId: string; }, QueryValues, unknown, UploadedFiles>.params: {
    orgId: string;
    projectId: string;
    taskId: string;
}
params
,
})

req.params is { orgId: string; projectId: string; taskId: string }: the prefixes of the groups count, nested groups included. A typo such as req.params.projectID is a compile error.

Give a params schema to validate or convert them: params: z.object({ taskId: z.uuid() }).

Schemas type the request

The handler receives the values parsed by the schemas: coerced, trimmed, with their defaults.

defineRoute<"/tasks", {
    query: ZodObject<{
        page: ZodDefault<ZodCoercedNumber<unknown>>;
        done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
        sort: ZodDefault<ZodEnum<{
            date: "date";
            title: "title";
        }>>;
    }, $strip>;
}, false, unknown[]>(route: RouteConfig<"/tasks", {
    query: ZodObject<{
        page: ZodDefault<ZodCoercedNumber<unknown>>;
        done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
        sort: ZodDefault<ZodEnum<{
            date: "date";
            title: "title";
        }>>;
    }, $strip>;
}, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/tasks", { query: ZodObject<{ page: ZodDefault<ZodCoercedNumber<unknown>>; done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>; sort: ZodDefault<...>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.path: "/tasks"
path
: '/tasks',
RouteConfig<"/tasks", { query: ZodObject<{ page: ZodDefault<ZodCoercedNumber<unknown>>; done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>; sort: ZodDefault<...>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.schema?: SchemaDefinition<{
    query: ZodObject<{
        page: ZodDefault<ZodCoercedNumber<unknown>>;
        done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
        sort: ZodDefault<ZodEnum<{
            date: "date";
            title: "title";
        }>>;
    }, $strip>;
}> | undefined
schema
: (
z: typeof import("zod")
z
) => ({
query: ZodObject<{
    page: ZodDefault<ZodCoercedNumber<unknown>>;
    done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
    sort: ZodDefault<ZodEnum<{
        date: "date";
        title: "title";
    }>>;
}, $strip>
query
:
z: typeof import("zod")
z
.
function object<{
    page: ZodDefault<ZodCoercedNumber<unknown>>;
    done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
    sort: ZodDefault<ZodEnum<{
        date: "date";
        title: "title";
    }>>;
}>(shape?: {
    page: ZodDefault<ZodCoercedNumber<unknown>>;
    done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
    sort: ZodDefault<ZodEnum<{
        date: "date";
        title: "title";
    }>>;
} | undefined, params?: string | {
    ...;
} | undefined): ZodObject<...>
object
({
page: ZodDefault<ZodCoercedNumber<unknown>>
page
:
z: typeof import("zod")
z
.
import coerce
coerce
.
function number<unknown>(params?: string | $ZodNumberParams): ZodCoercedNumber<unknown>
number
().
_ZodNumber<$ZodNumberInternals<unknown>>.int(params?: string | $ZodCheckNumberFormatParams): ZodCoercedNumber<unknown>

Consider z.int() instead. This API is considered legacy; it will never be removed but a better alternative exists.

int
().
_ZodNumber<$ZodNumberInternals<unknown>>.min(value: number, params?: string | $ZodCheckGreaterThanParams): ZodCoercedNumber<unknown>
min
(1).
ZodType<any, any, $ZodNumberInternals<unknown>>.default(def: number): ZodDefault<ZodCoercedNumber<unknown>> (+1 overload)
default
(1),
done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>
done
:
z: typeof import("zod")
z
.
const stringbool: (_params?: string | $ZodStringBoolParams) => ZodCodec<ZodString, ZodBoolean>
stringbool
().
ZodType<any, any, $ZodPipeInternals<ZodString, ZodBoolean>>.optional(): ZodOptional<ZodCodec<ZodString, ZodBoolean>>
optional
(),
sort: ZodDefault<ZodEnum<{
    date: "date";
    title: "title";
}>>
sort
:
z: typeof import("zod")
z
.
enum<readonly ["date", "title"]>(values: readonly ["date", "title"], params?: string | $ZodEnumParams): ZodEnum<{
    date: "date";
    title: "title";
}> (+1 overload)
export enum
enum
(['date', 'title']).
ZodType<any, any, $ZodEnumInternals<{ date: "date"; title: "title"; }>>.default(def: "date" | "title"): ZodDefault<ZodEnum<{
    date: "date";
    title: "title";
}>> (+1 overload)
default
('date'),
}), }),
RouteConfig<"/tasks", { query: ZodObject<{ page: ZodDefault<ZodCoercedNumber<unknown>>; done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>; sort: ZodDefault<...>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<{
    query: ZodObject<{
        page: ZodDefault<ZodCoercedNumber<unknown>>;
        done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
        sort: ZodDefault<ZodEnum<{
            date: "date";
            title: "title";
        }>>;
    }, $strip>;
}, {}, false, unknown>
handler
: ({
req: RouteRequest<{
    query: ZodObject<{
        page: ZodDefault<ZodCoercedNumber<unknown>>;
        done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
        sort: ZodDefault<ZodEnum<{
            date: "date";
            title: "title";
        }>>;
    }, $strip>;
}, {}, false>
req
}) => {
const {
const page: number
page
,
const done: boolean | undefined
done
,
const sort: "date" | "title"
sort
} =
req: RouteRequest<{
    query: ZodObject<{
        page: ZodDefault<ZodCoercedNumber<unknown>>;
        done: ZodOptional<ZodCodec<ZodString, ZodBoolean>>;
        sort: ZodDefault<ZodEnum<{
            date: "date";
            title: "title";
        }>>;
    }, $strip>;
}, {}, false>
req
.
DotpressRequest<{}, { page: number; sort: "date" | "title"; done?: boolean | undefined; }, unknown, UploadedFiles>.query: {
    page: number;
    sort: "date" | "title";
    done?: boolean | undefined;
}
query
return {
page: number
page
,
done: boolean | undefined
done
,
sort: "date" | "title"
sort
}
}, })

A query string only carries strings: z.coerce.number() turns ?page=2 into 2, and z.stringbool() turns ?done=true into true. The same applies to body and to uploaded files.

The response is checked

With a response schema, the value returned by the handler must match it. A missing field is a compile error, before any test runs:

defineRoute<"/me", EmptyObject, false, unknown[]>(route: RouteConfig<"/me", EmptyObject, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/me", EmptyObject, EmptyObject, false, unknown[], unknown>.path: "/me"
path
: '/me',
RouteConfig<"/me", EmptyObject, EmptyObject, false, unknown[], unknown>.schema?: SchemaDefinition<EmptyObject> | undefined
schema
: (
z: typeof import("zod")
z
) => ({
response: ZodObject<{
    id: ZodString;
    email: ZodEmail;
}, $strip>
response
:
z: typeof import("zod")
z
.
function object<{
    id: ZodString;
    email: ZodEmail;
}>(shape?: {
    id: ZodString;
    email: ZodEmail;
} | undefined, params?: string | {
    error?: string | $ZodErrorMap<NonNullable<$ZodIssueInvalidType<unknown> | $ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): ZodObject<{
    id: ZodString;
    email: ZodEmail;
}, $strip>
object
({
id: ZodString
id
:
z: typeof import("zod")
z
.
function string(params?: string | $ZodStringParams): ZodString (+1 overload)
string
(),
email: ZodEmail
email
:
z: typeof import("zod")
z
.
function email(params?: string | $ZodEmailParams): ZodEmail
email
() }),
}),
RouteConfig<"/me", EmptyObject, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<EmptyObject, {}, false, unknown>
handler
: () => ({ id: 'u_1' }),
Type '{ id: string; }' is not assignable to type 'RawResult | HttpError | { id: string; email: string; } | JsonResult<{ id: string; email: string; }> | Promise<RawResult | HttpError | { ...; } | JsonResult<...> | undefined> | undefined'. Property 'email' is missing in type '{ id: string; }' but required in type '{ id: string; email: string; }'.
})

At runtime, the response schema also validates and strips the value: a field that is not in the schema, such as a password hash, never reaches the client.

Errors stay allowed: a handler with a response schema can still return notFoundError().

Middlewares that guarantee a value

A middleware declared with defineMiddleware<{ … }>() tells TypeScript what it guarantees to the handlers that use it, directly or through a group.

import { 
const createRouteGroup: <const P extends string, const TProvided extends readonly unknown[] = unknown[]>(prefix: P, middlewares?: MiddlewareList<TProvided> | undefined) => RouteGroup<EmptyObject & PathParams<P>, UnionToIntersection<{ [K in keyof TProvided]: unknown extends TProvided[K] ? never : TProvided[K]; }[number]>>

Creates a route group on the global router.

createRouteGroup
,
const defineMiddleware: <TProvides = unknown>(middleware: (ctx: RequestContext) => MiddlewareResult | Promise<MiddlewareResult>, options?: MiddlewareOptions) => Middleware<TProvides>

Declares a middleware that guarantees context properties to handlers.

@example

const requireTenant = defineMiddleware<{ tenant: Tenant }>((ctx) => ctx.tenant ? undefined : forbiddenError() )

defineRoute({ path: '/settings', middlewares: [requireTenant], handler: ({ tenant }) => tenant.settings, // Tenant, not Tenant | undefined })

// Documented as secured by the bearerAuth scheme (@dotpress/openapi). const requireUser = defineMiddleware((ctx) => ..., { security: ['bearerAuth'] })

defineMiddleware
,
const forbiddenError: (message?: string, data?: ErrorData) => HttpError
forbiddenError
} from '@dotpress/core'
interface Tenant {
Tenant.id: string
id
: string
Tenant.plan: "free" | "pro"
plan
: 'free' | 'pro'
} declare const
const tenants: {
    byHost(host: string | undefined): Promise<Tenant | undefined>;
}
tenants
: {
function byHost(host: string | undefined): Promise<Tenant | undefined>
byHost
(
host: string | undefined
host
: string | undefined):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<Tenant | undefined> }
declare module '@dotpress/core' { interface RequestContext {
RequestContext.tenant?: Tenant | undefined
tenant
?: Tenant
} } const
const requireTenant: Middleware<{
    tenant: Tenant;
}>
requireTenant
=
defineMiddleware<{
    tenant: Tenant;
}>(middleware: (ctx: RequestContext) => MiddlewareResult | Promise<MiddlewareResult>, options?: MiddlewareOptions): Middleware<{
    tenant: Tenant;
}>

Declares a middleware that guarantees context properties to handlers.

@example

const requireTenant = defineMiddleware<{ tenant: Tenant }>((ctx) => ctx.tenant ? undefined : forbiddenError() )

defineRoute({ path: '/settings', middlewares: [requireTenant], handler: ({ tenant }) => tenant.settings, // Tenant, not Tenant | undefined })

// Documented as secured by the bearerAuth scheme (@dotpress/openapi). const requireUser = defineMiddleware((ctx) => ..., { security: ['bearerAuth'] })

defineMiddleware
<{
tenant: Tenant
tenant
: Tenant }>(async (
ctx: RequestContext
ctx
) => {
ctx: RequestContext
ctx
.
RequestContext.tenant?: Tenant | undefined
tenant
= await
const tenants: {
    byHost(host: string | undefined): Promise<Tenant | undefined>;
}
tenants
.
function byHost(host: string | undefined): Promise<Tenant | undefined>
byHost
(
ctx: RequestContext
ctx
.
RequestContext.req: DotpressRequest<Record<string, string>, QueryValues, unknown, UploadedFiles>
req
.
DotpressRequest<Record<string, string>, QueryValues, unknown, UploadedFiles>.header(name: string): string | undefined

Returns a header value (case-insensitive).

header
('host'))
if (!
ctx: RequestContext
ctx
.
RequestContext.tenant?: Tenant | undefined
tenant
) return
function forbiddenError(message?: string, data?: ErrorData): HttpError
forbiddenError
('Unknown tenant')
}) const
const settings: RouteGroup<EmptyObject & {}, {
    tenant: Tenant;
}>
settings
=
createRouteGroup<"/settings", readonly [{
    tenant: Tenant;
}]>(prefix: "/settings", middlewares?: readonly [Middleware<{
    tenant: Tenant;
}>] | undefined): RouteGroup<EmptyObject & {}, {
    tenant: Tenant;
}>

Creates a nested group, inheriting the prefix and middlewares. Context properties guaranteed by the group middlewares are typed in the handlers of its routes.

createRouteGroup
('/settings', [
const requireTenant: Middleware<{
    tenant: Tenant;
}>
requireTenant
])
const settings: RouteGroup<EmptyObject & {}, {
    tenant: Tenant;
}>
settings
.
RouteGroup<EmptyObject & {}, { tenant: Tenant; }>.defineRoute<"/", EmptyObject, false, unknown[]>(route: RouteConfig<"/", EmptyObject, EmptyObject & {}, false, unknown[], {
    tenant: Tenant;
}>): void
defineRoute
({
RouteConfig<"/", EmptyObject, EmptyObject & {}, false, unknown[], { tenant: Tenant; }>.path: "/"
path
: '/',
RouteConfig<"/", EmptyObject, EmptyObject & {}, false, unknown[], { tenant: Tenant; }>.handler: RouteHandler<EmptyObject, {}, false, {
    tenant: Tenant;
}>
handler
: ({
tenant: Tenant
tenant
}) => ({
plan: "free" | "pro"
plan
:
tenant: Tenant
tenant
.
Tenant.plan: "free" | "pro"
plan
}),
})

In the routes of the group, tenant is a Tenant, not Tenant | undefined: no non-null assertion, no repeated check. Elsewhere, it stays optional.

The authenticated user

@dotpress/auth works the same way. Describe your user once, and requireAuth guarantees it:

import { 
const defineRoute: <const P extends string, S extends RouteSchema = EmptyObject, TRawBody extends boolean = false, const TProvided extends readonly unknown[] = unknown[]>(route: RouteConfig<P, S, EmptyObject, TRawBody, TProvided, unknown>) => void

Declares a route on the global router.

defineRoute
} from '@dotpress/core'
import {
const requireAuth: Middleware<AuthenticatedContext>

Requires an authenticated user (401 UNAUTHORIZED otherwise). In the handlers of the routes using it, ctx.user and ctx.auth are defined.

requireAuth
} from '@dotpress/auth'
declare module '@dotpress/auth' { interface AuthUser {
AuthUser.id: string
id
: string
AuthUser.email: string
email
: string
AuthUser.roles: string[]
roles
: string[]
} }
defineRoute<"/profile", EmptyObject, false, readonly [AuthenticatedContext]>(route: RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>.path: "/profile"
path
: '/profile',
RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>.middlewares?: readonly [Middleware<AuthenticatedContext>] | undefined

Route middlewares, run after global and group middlewares. Context properties they guarantee (defineMiddleware()) are typed in the handler.

middlewares
: [
const requireAuth: Middleware<AuthenticatedContext>

Requires an authenticated user (401 UNAUTHORIZED otherwise). In the handlers of the routes using it, ctx.user and ctx.auth are defined.

requireAuth
],
RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>.handler: RouteHandler<EmptyObject, {}, false, AuthenticatedContext>
handler
: ({
user: AuthUser

Authenticated user (authPlugin). Use requireAuth to require it.

user
}) => ({
email: string
email
:
user: AuthUser

Authenticated user (authPlugin). Use requireAuth to require it.

user
.
AuthUser.email: string
email
}),
})

See Protecting routes.

Context keys and services

The keys added by plugins (ctx.mailer, ctx.events, ctx.cache) and by your app (ctx.services) are typed in every handler: your editor completes their methods and checks their arguments. A mail template, for instance, checks the data it receives. See Request context.

Where types stop

Types describe what your code does; schemas check what clients send. Dotpress uses both:

  • at compile time, TypeScript checks your handlers against the types inferred from the schemas;
  • at runtime, the schemas validate every request, and the response when there is a response schema.

A client can send anything: only the schemas protect the handler from it. Declare a schema for every input your handler reads.