#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>): voidDeclares 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.
@exampleconst 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.
@exampleconst 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 | undefinedReturns 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>) => voidDeclares 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>): voidDeclares 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>] | undefinedRoute 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: AuthUserAuthenticated user (authPlugin). Use requireAuth to require it.
user }) => ({ email: string email : user: AuthUserAuthenticated 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
responseschema.
A client can send anything: only the schemas protect the handler from it. Declare a schema for every input your handler reads.