trpc-diff
v1.2.0
Published
Detect breaking changes in tRPC routers
Maintainers
Readme
What it does
trpc-diff converts your tRPC router to an OpenAPI contract and diffs two contracts to find breaking changes.
Under the hood it uses:
- zod-openapi to generate the OpenAPI contract from your Zod schemas
- oasdiff-js (JavaScript bindings for oasdiff) to detect breaking changes between contracts
Install
npm install -D trpc-diff
# or
bun add -D trpc-diffMake sure you have @trpc/server installed (peer dependency).
Usage
Import your router, generate a contract, and diff it against another version.
Generate a contract
import { generateContract, zodAdapter } from "trpc-diff";
import { appRouter } from "./server/router";
const contract = generateContract(appRouter, [zodAdapter]);
// write to disk or pass directly to diffContracts
await Bun.write("contract.json", JSON.stringify(contract, null, 2));You must provide at least one schema adapter. zodAdapter is included for Zod schemas. You can also bring your own adapter for other parsers (e.g. Valibot, ArkType, or custom validators).
Custom adapters
If your router uses multiple parser libraries, you can provide multiple adapters:
const contract = generateContract(appRouter, [zodAdapter, myValibotAdapter]);An adapter implements the IParserAdapter<TParser> interface:
export interface IParserAdapter<TParser = unknown> {
isParser(value: unknown): value is TParser;
mergeInputs(inputs: TParser[]): TParser | null;
toSchema(parser: TParser, io: "input" | "output"): unknown;
}If a procedure chains inputs from different parser families, their resulting OpenAPI schemas are merged with { allOf: [...] }.
Diff two contracts
import { diffContracts } from "trpc-diff";
import base from "./contract-base.json";
import head from "./contract-head.json";
const result = await diffContracts(base, head);
if (!result.compatible) {
console.log("Breaking changes found:");
for (const finding of result.findings) {
console.log(`- ${finding.code} at ${finding.entity}`);
}
}Options
Custom severity levels
const result = await diffContracts(base, head, {
severityLevels: {
// treat removing request properties as breaking
"request-property-removed": "err",
// ignore response enum value additions
"response-body-enum-value-added": "none",
},
});Each key is a check id and each value is a level (err, warn, info, or none). This emulates an oasdiff-levels.txt file.
Defaults
By default, only request-property-removed is overridden to none (removing request properties is considered compatible). This matches typical tRPC behavior where Zod input schemas are non-strict by default, so removing a field from the schema doesn't necessarily break existing clients.
Everything else follows oasdiff's default severity levels.
To see all available check ids and their default levels, install @oasdiff-js/oasdiff-js directly and run:
npx oasdiff checksExit on missing adapter
By default, if a procedure uses a parser that none of the provided adapters can handle, generateContract will throw and exit. You can make it skip and log it instead:
const contract = generateContract(appRouter, [zodAdapter], {
exitOnMissingAdapter: false,
});ESLint rule
trpc-diff can only generate accurate response contracts when procedures explicitly declare .output(). tRPC can infer outputs from resolver return types, but that type information is not available from the runtime router metadata used by this library.
You can opt into the bundled ESLint rule to enforce explicit outputs:
import trpcDiff from "trpc-diff/eslint";
export default [
{
plugins: {
"trpc-diff": trpcDiff,
},
rules: {
"trpc-diff/require-output": "error",
},
},
];If a procedure intentionally has no output, use .output(z.void()).
Using it in CI
Since the library is runtime-agnostic, you can diff PRs in CI by generating contracts in each checkout with the same runtime your app uses.
- name: Generate base contract
run: bun run scripts/generate-contract.ts --out base.json
- name: Generate head contract
run: bun run scripts/generate-contract.ts --out head.json
- name: Diff contracts
run: bun run scripts/diff-contracts.ts --base base.json --head head.jsonAPI reference
generateContract(router, adapters, options?): IOpenApiDocument
Converts a tRPC router to an OpenAPI 3.0 contract.
| Parameter | Type | Description |
| ------------------------------ | ------------------ | ---------------------------------------------------- |
| router | AnyRouter | tRPC router |
| adapters | IParserAdapter[] | Adapters for parsing input/output schemas |
| options.exitOnMissingAdapter | boolean | Throw when a parser has no adapter (default: true) |
diffContracts(base, head, options?): Promise<IDiffResult>
Diffs two OpenAPI contracts for breaking changes.
| Parameter | Type | Description |
| ------------------------ | ------------------------ | --------------------------- |
| base | IOpenApiDocument | Base contract |
| head | IOpenApiDocument | Head contract |
| options.severityLevels | Record<string, string> | Optional severity overrides |
Returns { compatible: boolean; findings: IDiffFinding[] }.
Design
trpc-diff is a library and not a CLI tool because tRPC routers are runtime values.
Extracting their schemas requires executing the module that defines them, which in turn requires the correct runtime, module resolver, and loader for your specific project. Rather than bundling a TypeScript loader or importing user code on your behalf, the library exposes the primitives so you can run them in your own runtime context.
