Skip to content
tRPC

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). 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?.defined && err.code === 'RATE_LIMITED') retryIn(err.data.retryAfter);
client.onPost.subscribe; // type error: no link carries subscriptions

Reading 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: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}>, readonly [...]>(opts: TypedClientOptions<...>): TrpcClient<...>
createTrpcClient
({
TypedClientOptions<RouterType<{ post: { byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{ id: ZodString; }, $strip>>, "query", { output: Post; errors: TrpcError<...>; services: never; unmapped: never; }>; list: BuildProcedure<...>; create: BuildProcedure<...>; }; }>, readonly [...]>.router: RouterType<{
    post: {
        byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}>
router
:
routerType<{
    post: {
        byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}>(): RouterType<...>
routerType
<
type AppRouter = {
    post: {
        byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}
AppRouter
>(),
TypedClientOptions<RouterType<{ post: { byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{ id: ZodString; }, $strip>>, "query", { output: Post; errors: TrpcError<...>; services: never; unmapped: never; }>; list: BuildProcedure<...>; create: BuildProcedure<...>; }; }>, 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: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}>, readonly [...]>(opts: TypedClientOptions<...> & {
    ...;
}): TrpcClient<...>
createTrpcClient
({
TypedClientOptions<RouterType<{ post: { byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{ id: ZodString; }, $strip>>, "query", { output: Post; errors: TrpcError<...>; services: never; unmapped: never; }>; list: BuildProcedure<...>; create: BuildProcedure<...>; }; }>, readonly [...]>.router: RouterType<{
    post: {
        byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}>
router
:
routerType<{
    post: {
        byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}>(): RouterType<...>
routerType
<
type AppRouter = {
    post: {
        byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
            id: ZodString;
        }, $strip>>, "query", {
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }>;
            services: never;
            unmapped: never;
        }>;
        list: BuildProcedure<RootDef<Context, {}>, "query", {
            output: Post[];
            errors: never;
            services: never;
            unmapped: never;
        }>;
        create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
            ctx: Context;
            ctxAdded: {
                ...;
            };
            inputOut: Unset;
            errors: TrpcError<...>;
            shorts: never;
        }>, ZodObject<...>>, "mutation", {
            ...;
        }>;
    };
}
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 '[TrpcLink<{ form: JsonForm; provides: "response"; }, any>, TrpcLink<{ needs: "response"; }, any>]' is not assignable to type 'readonly [TrpcLink<{ form: JsonForm; provides: "response"; }, any>, TrpcLink<{ needs: "response"; }, any>] & "A link needs 'response' from a link after it"'. Type '[TrpcLink<{ form: JsonForm; provides: "response"; }, any>, TrpcLink<{ needs: "response"; }, any>]' is not assignable to type '"A link needs 'response' from a link after it"'.
}); // type error

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).