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
npm install -D trpc-diff
# or
bun add -D trpc-diffMake sure you have @trpc/server installed (peer dependency).
Import your router, generate a contract, and diff it against another version.
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).
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: [...] }.
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}`);
}
}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.
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 checksBy 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,
});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()).
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.jsonConverts 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) |
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[] }.
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.