Custom links
Write a link with link(), and declare the call options it reads, the errors it fails with and what it needs.
On this page
link(({ op, next }) => stream) writes a link: it gets the operation and the rest of the chain, and returns an Effect Stream (a query or mutation is a one-element stream). Links written for v11 run unchanged through fromV11Link from @trpc12/trpc/interop.
Call options
A link of your own declares what it reads, and the client types it as a call option:
const const traceLink: TrpcLink<{
context: {
traceId?: string;
};
}, any>
traceLink = link<{
context: {
traceId?: string;
};
}>(fn: (opts: EffectLinkOpts<{
traceId?: string;
}>) => OperationStream): TrpcLink<{
context: {
traceId?: string;
};
}, any>
link<{ context: {
traceId?: string;
}
context: { traceId?: string | undefinedtraceId?: string } }>((opts: EffectLinkOpts<{
traceId?: string;
}>
opts) => {
function span(traceId: string | undefined): voidspan(opts: EffectLinkOpts<{
traceId?: string;
}>
opts.EffectLinkOpts<{ traceId?: string; }>.op: Operation<{
traceId?: string;
}>
op.Operation<{ traceId?: string; }>.context: BuiltinCallOptions & Partial<{
traceId?: string;
}>
context.traceId?: string | undefinedtraceId);
return opts: EffectLinkOpts<{
traceId?: string;
}>
opts.EffectLinkOpts<{ traceId?: string; }>.next: (op: Operation) => OperationStreamnext(opts: EffectLinkOpts<{
traceId?: string;
}>
opts.EffectLinkOpts<{ traceId?: string; }>.op: Operation<{
traceId?: string;
}>
op);
});
// client.post.byId.query(input, { traceId: 'abc' })Links receive the fields in op.context. signal and lastEventId are reserved: a link declaring either is a type error. The built-in fields (headers, init, batch, reconnect, onResponse) are always there; anything else comes from what your links declare.
Errors and unsupported types
A link can also declare the errors it fails calls with, and the operation types it can't carry. Declared errors join every call's typed union (defined: true). An error a link didn't declare reaches callers as a built-in one: a custom code becomes CLIENT_ERROR, and a built-in code loses its data. Procedures of an unsupported type lose their method:
class RateLimited extends TrpcError.Tagged('RATE_LIMITED', {
data: Schema.Struct({ retryAfter: Schema.Number }),
}) {}
const rateLimit = link<{ errors: RateLimited }>((opts) =>
budget.take()
? opts.next(opts.op)
: Stream.fail(new RateLimited({ retryAfter: 5 })),
);
const restOnly = link<{ unsupported: 'subscription' }>(/* … */);
const client = createTrpcClient({
router: routerType<AppRouter>(),
links: [rateLimit, restOnly, httpLink({ url })],
});
const [, err] = await safe(client.post.create.mutate(input));
if (err?.code === 'RATE_LIMITED') retryIn(err.data.retryAfter); // a custom code is always defined
client.onPost.subscribe; // type error: no link carries subscriptionsReading the response
httpLink hands each call's response to an onResponse call option ({ status, headers }), before the value goes up the chain. A batched call gets its own line's status and the batch's headers. A link that reads it declares needs: 'response', and the client rejects a links array with no link after it that provides: 'response':
const const versionLink: TrpcLink<{
needs: "response";
}, any>
versionLink = link<{
needs: "response";
}>(fn: (opts: EffectLinkOpts<{}>) => OperationStream): TrpcLink<{
needs: "response";
}, any>
link<{ needs: "response"needs: 'response' }>((opts: EffectLinkOpts<{}>opts) =>
opts: EffectLinkOpts<{}>opts.EffectLinkOpts<{}>.next: (op: Operation) => OperationStreamnext({
...opts: EffectLinkOpts<{}>opts.EffectLinkOpts<{}>.op: Operation<{}>op,
Operation<{}>.context: BuiltinCallOptions & Partial<{}>context: {
...opts: EffectLinkOpts<{}>opts.EffectLinkOpts<{}>.op: Operation<{}>op.Operation<{}>.context: BuiltinCallOptions & Partial<{}>context,
onResponse?: ((meta: ResponseMeta) => void) | undefinedonResponse: (res: ResponseMetares) => {
function checkVersion(version: string | null): voidcheckVersion(res: ResponseMetares.ResponseMeta.headers: Headersheaders.Headers.get(name: string): string | nullget('x-version'));
opts: EffectLinkOpts<{}>opts.EffectLinkOpts<{}>.op: Operation<{}>op.Operation<{}>.context: BuiltinCallOptions & Partial<{}>context.onResponse?: ((meta: ResponseMeta) => void) | undefinedonResponse?.(res: ResponseMetares);
},
},
}),
);
createTrpcClient<RouterType<{
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;
}>;
};
}>, readonly [...]>(opts: TypedClientOptions<...>): TrpcClient<...>
createTrpcClient({
TypedClientOptions<RouterType<{ 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<...>; }; }>, readonly [...]>.router: RouterType<{
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: routerType<{
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;
}>;
};
}>(): RouterType<...>
routerType<type 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>(),
TypedClientOptions<RouterType<{ 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<...>; }; }>, readonly [...]>.links: readonly [TrpcLink<{
needs: "response";
}, any>, TrpcLink<{
form: JsonForm;
provides: "response";
}, any>]
links: [const versionLink: TrpcLink<{
needs: "response";
}, any>
versionLink, httpLink<{
url: string;
}>(opts: {
url: string;
}): TrpcLink<{
form: JsonForm;
provides: "response";
}, any>
httpLink({ url: stringurl })],
});
createTrpcClient<RouterType<{
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;
}>;
};
}>, readonly [...]>(opts: TypedClientOptions<...> & {
...;
}): TrpcClient<...>
createTrpcClient({
TypedClientOptions<RouterType<{ 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<...>; }; }>, readonly [...]>.router: RouterType<{
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: routerType<{
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;
}>;
};
}>(): RouterType<...>
routerType<type 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>(),
links: [httpLink<{
url: string;
}>(opts: {
url: string;
}): TrpcLink<{
form: JsonForm;
provides: "response";
}, any>
httpLink({ url: stringurl }), const versionLink: TrpcLink<{
needs: "response";
}, any>
versionLink],}); // type errorBranching links
splitLink and switchLink lack an operation type only when every branch does, and provide only what every branch provides. Built-in links carry every type, and their failures (NETWORK_ERROR, CLIENT_CLOSED_REQUEST) stay in the untyped part of the union.
Typing integrations from the client
TanStack context helpers read link types from the client factory's or the client's type, createTrpcContext<typeof makeClient>() (ClientTypes<T> gives a client type's router, link context and link types).
For building an integration of your own, @trpc12/trpc/extend has the untyped client and the link runner (Extensions).