ExperimentalContracts and OpenAPI might change or go away in any release. Stability
REST routes
Serve the same router over REST: route meta, the handler, inputs, outputs and groups.
On this page
A procedure gets a REST route from its route meta, and experimental_createOpenApiHandler serves the routes next to the RPC handler (OpenAPI).
Route meta
The experimental_openapi() root extension adds a route meta key. A procedure without one is served at its path: GET /api/post/byId for queries, POST /api/post/create for mutations. route picks the method, path and status.
import { function experimental_openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>experimental_openapi } from '@trpc12/trpc/openapi';
import { import zz } from 'zod';
const const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t = initTrpc<{
ctx: Context;
}>(opts?: RootOptions<{}>): TrpcRoot<Context, {}, RootDef<Context, {}>>
initTrpc<{ ctx: Contextctx: Context }>().TrpcRoot<Context, {}, RootDef<Context, {}>>.concat<RootDef<{}, OpenApiMeta>>(extension: ProcedureBuilder<RootDef<{}, OpenApiMeta>>): TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
concat(function experimental_openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>experimental_openapi());
const const byId: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>;
services: never;
}>
byId = const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t.TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.procedure: ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>procedure
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.meta<{
readonly route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
};
}>(meta: {
readonly route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
};
} & UndeclaredMeta<{
route?: Route | undefined;
}, {
readonly route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
};
}>): ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
meta({ route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
}
route: { method: "GET"method: 'GET', path: "/posts/{id}"path: '/posts/{id}', tags: readonly ["posts"]tags: ['posts'] } })
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.input<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>(schema: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>): ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>> (+1 overload)
input(import zz.function object<{
id: z.ZodString;
}>(shape?: {
id: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>
object({ id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }))
.ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>>.errors<{
readonly NOT_FOUND: {
readonly data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
};
}>(map: {
readonly NOT_FOUND: {
readonly data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
};
}): ProcedureBuilder<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<...>;
}>>
errors({ type NOT_FOUND: {
readonly data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
}
NOT_FOUND: { data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>
data: import zz.function object<{
id: z.ZodString;
}>(shape?: {
id: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>
object({ id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }) } })
.ProcedureBuilder<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.query<Post | TrpcError<"NOT_FOUND", {
id: string;
}>>(resolver: (opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>;
}>>) => Post | TrpcError<...>): Procedure<...>
query(
(opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>;
}>>
opts) =>
const db: {
post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
};
}
db.post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
}
post.find: (id: string) => Post | undefinedfind(opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>;
}>>
opts.ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.input: {
id: string;
}
input.id: stringid) ??
opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>;
}>>
opts.ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.errors: ErrorConstructors<{
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
}>
errors.type NOT_FOUND: (opts: ErrorConstructorOpts<{
id: string;
}>) => TrpcError<"NOT_FOUND", {
id: string;
}>
NOT_FOUND({ data: {
id: string;
}
data: { id: stringid: opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>;
}>>
opts.ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.input: {
id: string;
}
input.id: stringid } }),
);
const const create: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: {
title: string;
};
output: Post;
errors: SchemaValidationError<z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>;
services: never;
}>
create = const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t.TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.procedure: ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>procedure
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.meta<{
readonly route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
};
}>(meta: {
readonly route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
};
} & UndeclaredMeta<{
route?: Route | undefined;
}, {
readonly route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
};
}>): ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
meta({ route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
}
route: { method: "POST"method: 'POST', path: "/posts"path: '/posts', successStatus: 201successStatus: 201 } })
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.input<z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>(schema: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>): ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>> (+1 overload)
input(import zz.function object<{
title: z.ZodString;
}>(shape?: {
title: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>
object({ title: z.ZodStringtitle: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1) }))
.ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ title: ZodString; }, $strip>>>.mutation<Post>(resolver: (opts: ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>>) => Post): Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: {
title: string;
};
output: Post;
errors: SchemaValidationError<z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>;
services: never;
}>
mutation((opts: ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>>
opts) => const db: {
post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
};
}
db.post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
}
post.create: (input: {
title: string;
author?: string | null;
}) => Post
create(opts: ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>>
opts.ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ title: ZodString; }, $strip>>>.input: {
title: string;
}
input));route also takes summary, description, deprecated and operationId. When two paths match, the one with more leading static segments wins: /posts/latest before /posts/{id}.
spec overrides the generated operation in the document: an object replaces it, a function extends it. Later .meta({ route }) calls merge into earlier ones key by key, and spec functions compose, so a base procedure can add security and its procedures add more:
const const authed: ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
user: User;
};
inputOut: Unset;
errors: TrpcError<"UNAUTHORIZED", undefined>;
shorts: never;
}>>
authed = const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t.TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.procedure: ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>procedure
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.use<{
user: User;
}, TrpcError<"UNAUTHORIZED", undefined>, never, Unset, {}>(fn: MiddlewareFunction<{
user: User | null;
}, undefined, {
route?: Route | undefined;
}, {
user: User;
}, TrpcError<"UNAUTHORIZED", undefined>, never, Unset>): ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
user: User;
};
inputOut: Unset;
errors: TrpcError<"UNAUTHORIZED", undefined>;
shorts: never;
}>> (+1 overload)
use(const requireUser: MiddlewareFunction<{
user: User | null;
}, unknown, {
route?: Route | undefined;
}, {
user: User;
}, TrpcError<"UNAUTHORIZED", undefined>, never, Unset>
requireUser)
.ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.meta<{
readonly route: {
readonly spec: (op: OperationSpec) => {
security: {
bearer: never[];
}[];
};
};
}>(meta: {
readonly route: {
readonly spec: (op: OperationSpec) => {
security: {
bearer: never[];
}[];
};
};
} & UndeclaredMeta<{
route?: Route | undefined;
}, {
readonly route: {
readonly spec: (op: OperationSpec) => {
security: {
bearer: never[];
}[];
};
};
}>): ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>>
meta({ route: {
readonly spec: (op: OperationSpec) => {
security: {
bearer: never[];
}[];
};
}
route: { spec: (op: OperationSpec) => {
security: {
bearer: never[];
}[];
}
spec: (op: OperationSpecop) => ({ ...op: OperationSpecop, security: {
bearer: never[];
}[]
security: [{ bearer: never[]bearer: [] }] }) } });
const const me: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: User;
errors: TrpcError<"UNAUTHORIZED", undefined>;
services: never;
}>
me = const authed: ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
user: User;
};
inputOut: Unset;
errors: TrpcError<"UNAUTHORIZED", undefined>;
shorts: never;
}>>
authed
.ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.meta<{
readonly route: {
readonly path: "/me";
readonly spec: (op: OperationSpec) => {
'x-internal': boolean;
};
};
}>(meta: {
readonly route: {
readonly path: "/me";
readonly spec: (op: OperationSpec) => {
'x-internal': boolean;
};
};
} & UndeclaredMeta<{
route?: Route | undefined;
}, {
readonly route: {
readonly path: "/me";
readonly spec: (op: OperationSpec) => {
'x-internal': boolean;
};
};
}>): ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
...;
}>>
meta({
route: {
readonly path: "/me";
readonly spec: (op: OperationSpec) => {
'x-internal': boolean;
};
}
route: { path: "/me"path: '/me', spec: (op: OperationSpec) => {
'x-internal': boolean;
}
spec: (op: OperationSpecop) => ({ ...op: OperationSpecop, 'x-internal': true }) },
})
.ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.query<User>(resolver: (opts: ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
user: User;
};
inputOut: Unset;
errors: TrpcError<"UNAUTHORIZED", undefined>;
shorts: never;
}>>) => User): Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: User;
errors: TrpcError<"UNAUTHORIZED", undefined>;
services: never;
}>
query((opts: ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
user: User;
};
inputOut: Unset;
errors: TrpcError<"UNAUTHORIZED", undefined>;
shorts: never;
}>>
opts) => opts: ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
ctx: Context;
ctxAdded: {
user: User;
};
inputOut: Unset;
errors: TrpcError<"UNAUTHORIZED", undefined>;
shorts: never;
}>>
opts.ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.ctx: {
user: User;
}
ctx.user: Useruser);spec only changes the document (target: 'openapi'); experimental_toContract() leaves it out.
The handler
import { function cors(options?: CorsOptions): HttpMiddlewarecors } from '@trpc12/trpc/http';
import {
function experimental_createOpenApiHandler<TRouter extends AnyRouter, TPlatform = undefined>(opts: OpenApiHandlerOptions<TRouter, TPlatform>): OpenApiHandler<TPlatform>experimental_createOpenApiHandler,
function experimental_openApiReference(opts: OpenApiReferenceOptions): HttpMiddlewareexperimental_openApiReference,
} from '@trpc12/trpc/openapi';
const const openapi: OpenApiHandler<undefined>openapi = experimental_createOpenApiHandler<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, undefined>(opts: OpenApiHandlerOptions<...>): OpenApiHandler<...>
experimental_createOpenApiHandler({
BaseOpenApiHandlerOptions<{ post: { byId: Procedure<{ type: "query"; ctx: Context; meta: {}; input: { id: string; }; output: Post; errors: TrpcError<"NOT_FOUND", { id: string; }> | SchemaValidationError<ZodObject<{ id: ZodString; }, $strip>>; services: never; }>; list: Procedure<...>; create: Procedure<...>; }; }>.router: {
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}
router: const appRouter: {
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}
appRouter,
createContext: (opts: CreateContextOpts<undefined>) => MaybePromise<{
user: User | null;
}>
createContext,
layer?: Layer.Layer<never, any, never> | undefinedlayer,
BaseOpenApiHandlerOptions<TRouter extends AnyRouter>.middleware?: readonly HttpMiddleware[] | undefinedmiddleware: [
function cors(options?: CorsOptions): HttpMiddlewarecors(),
function experimental_openApiReference(opts: OpenApiReferenceOptions): HttpMiddlewareexperimental_openApiReference({
router: objectrouter: const appRouter: {
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}
appRouter,
specGenerateOptions: GenerateOpenApiOptionsspecGenerateOptions: { GenerateOpenApiOptions.info: OpenApiInfoinfo: { OpenApiInfo.title: stringtitle: 'API', OpenApiInfo.version: stringversion: '1.0.0' } },
}),
],
});
// GET /api/posts/1, GET /api/docs (Scalar), GET /api/openapi.jsonprefix defaults to /api. Like the RPC handler, it has fetch(request) and dispose(), and middleware takes Effect HTTP middleware, outermost first.
experimental_openApiReference() is middleware too, so it also works on createFetchHandler or any effect/http app. It answers GET /api/docs and GET /api/openapi.json and passes other requests through:
docsProvider: 'swagger'serves Swagger UI instead of Scalar;pathandspecPathmove the two pages.{ router, specGenerateOptions }generates the document on the first request.{ document }serves one you built, e.g. with the TypeScript generator below; it can be a function.- Without
servers, the document lists the request's origin.
Cross-site forms
The handler refuses requests from another origin that use a method other than GET, HEAD or OPTIONS with a body that isn't application/json, answering 403 FORBIDDEN. A form or a no-cors fetch on another site can send a urlencoded, multipart or text/plain body (or none) without a CORS preflight, and the browser attaches the user's cookies. A JSON body needs a preflight, so cors() decides those requests.
Sec-Fetch-Sitedecides:same-originandnonepass, whilesame-siteandcross-sitedon't, so a sibling subdomain counts as another origin. Browsers from before 2023 don't send that header; for them, theOriginheader must name the request's host.- A request with neither header (from
curlor a server) passes. csrf: { trustedOrigins: ['https://www.example.com'] }lets that origin's forms through.csrf: falseturns the guard off.
Input
With the default inputStructure: 'compact', path params merge with the query string (GET) or the body into one input.
- Query strings and forms use bracket notation (
?tags[]=a&author[name]=x) and are coerced by the input's JSON Schema, so?limit=10&published=truegives a number and a boolean. - JSON bodies are taken as they are.
multipart/form-datafields can holdFiles.- Forms from another origin are refused unless it's trusted (Cross-site forms).
inputStructure: 'detailed' passes { params, query, headers, body }, each with its own schema. outputStructure: 'detailed' lets the resolver return { status?, headers?, body }.
const update = t.procedure
.meta({
route: {
method: 'PATCH',
path: '/posts/{id}',
inputStructure: 'detailed',
outputStructure: 'detailed',
},
})
.input(
z.object({
params: z.object({ id: z.string() }),
headers: z.object({ 'if-match': z.string() }),
body: z.object({ title: z.string() }),
}),
)
.mutation((opts) => ({ headers: { etag: '…' }, body: savePost(opts.input) }));Output and errors
- The output is the JSON body. A
BloborFileoutput is sent as the body. Promises and async iterables inside an output aren't supported over REST. - Set
response.status = 204in a resolver to send no body. - Declared errors use their declared status, or their code's:
404 { defined: true, code: 'NOT_FOUND', message, data }. The body has nostatus; the response's status is it. Invalid input is a400withissues. - Unexpected errors are masked as
500 Internal server errorunlessexposeUnexpectedErrorsis on. It defaults to on only in development and test. - Subscriptions are
text/event-streamand resume fromLast-Event-ID.
Groups and lazy subtrees
Under the experimental_openapi() extension, t.router({ meta: { route: { prefix, tags } } }, routes) groups routes for OpenAPI. Types and RPC paths don't change, and it takes no middleware.
const const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t = initTrpc<{
ctx: Context;
}>(opts?: RootOptions<{}>): TrpcRoot<Context, {}, RootDef<Context, {}>>
initTrpc<{ ctx: Contextctx: Context }>().TrpcRoot<Context, {}, RootDef<Context, {}>>.concat<RootDef<{}, OpenApiMeta>>(extension: ProcedureBuilder<RootDef<{}, OpenApiMeta>>): TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
concat(function experimental_openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>experimental_openapi());
const const appRouter: {
admin: {
readonly users: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: {
id: string;
name: string;
}[];
errors: never;
services: never;
}>;
readonly ban: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: void;
errors: never;
services: never;
}>;
};
}
appRouter = {
admin: {
readonly users: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: {
id: string;
name: string;
}[];
errors: never;
services: never;
}>;
readonly ban: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: void;
errors: never;
services: never;
}>;
}
admin: const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t.TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.router<{
readonly users: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: {
id: string;
name: string;
}[];
errors: never;
services: never;
}>;
readonly ban: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: void;
errors: never;
services: never;
}>;
}>(opts: RouterOptions<RouterMeta<{
route?: Route | undefined;
}>>, routes: {
readonly users: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: {
id: string;
name: string;
}[];
errors: never;
services: never;
}>;
readonly ban: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: void;
errors: never;
services: never;
}>;
}): {
readonly users: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: {
id: string;
name: string;
}[];
errors: never;
services: never;
}>;
readonly ban: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: void;
errors: never;
services: never;
}>;
}
router(
{ RouterOptions<RouterMeta<{ route?: Route | undefined; }>>.meta?: RouterMeta<{
route?: Route | undefined;
}> | undefined
meta: { route?: RouteGroup | undefinedroute: { RouteGroup.prefix?: `/${string}` | undefinedprefix: '/staff', RouteGroup.tags?: readonly string[] | undefinedtags: ['admin'] } } },
{ users: Procedure<{
type: "query";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: {
id: string;
name: string;
}[];
errors: never;
services: never;
}>
users, ban: Procedure<{
type: "mutation";
ctx: Context;
meta: {
route?: Route | undefined;
};
input: undefined;
output: void;
errors: never;
services: never;
}>
ban },
),
};
// RPC: admin.users. OpenAPI: GET /api/staff/users, tagged admin.The prefix goes before every route below the group: before an explicit .meta({ route: { path } }), and before the path derived from the keys below the group. Group tags come before a procedure's own. Groups nest.
A router's meta takes only the keys an extension gives a router-level form, like route; other meta is set on procedures.
A t.lazy(load, { meta: { route: { prefix } } }) subtree is scoped the same way (Lazy subtrees).
The handler loads unprefixed lazy subtrees at startup, and a prefixed one on the first request under its prefix. Pass a contract so every route is known up front and each subtree loads on its first request, whatever its prefix:
// build step
const const contract: ContractJson<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}>
contract = experimental_toContract<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}>(router: {
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}): ContractJson<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}>
experimental_toContract(await loadRouter<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}>(router: {
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}): Promise<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}>
loadRouter(const appRouter: {
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}
appRouter));
// server
experimental_createOpenApiHandler<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}, undefined>(opts: OpenApiHandlerOptions<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}, undefined>): OpenApiHandler<undefined>
experimental_createOpenApiHandler({ BaseOpenApiHandlerOptions<{ health: Procedure<{ type: "query"; ctx: {}; meta: {}; input: undefined; output: string; errors: never; services: never; }>; }>.router: {
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}
router: const appRouter: {
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}
appRouter, BaseOpenApiHandlerOptions<{ health: Procedure<{ type: "query"; ctx: {}; meta: {}; input: undefined; output: string; errors: never; services: never; }>; }>.contract?: ContractJson<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}> | undefined
contract });Credits
The REST mapping follows oRPC's OpenAPI support: every procedure served with no configuration (routing), bracket notation and coercion from the schema.