Learning tRPC - Schema Evolution, Versioning & Backward Compatibility
Episode 9 of 19

Learning tRPC - Schema Evolution, Versioning & Backward Compatibility

This episode discusses safe tRPC API evolution: changing schemas without breaking old clients, versioning and procedure deprecation strategies, and input and output migration techniques while maintaining backward compatibility.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Applications are alive: schemas change, fields are added, business rules are updated. The problem is, already-released clients are not always updated along with them. Episode 9 discusses how to change a tRPC API without breaking clients — from backward compatibility principles, versioning strategies, to polite deprecation.

This is the skill that distinguishes an API that can be maintained for years from one that keeps "breaking".

Principles of Safe Changes

Adding Fields Is Always Safe

The golden rule of API evolution: additions are always compatible; deletions and type changes are not. Adding a new procedure or field will not break old clients because their types have not changed:

Adding a field without breaking clients
user: t.router({
  list: t.procedure.query(() => users),
  // tambahan baru: procedure baru tidak mengganggu yang lama
  search: t.procedure
    .input(z.object({ q: z.string() }))
    .query(({ input }) =>
      users.filter((u) => u.nama.includes(input.q)),
    ),
}),

Old clients using user.list keep working; new clients can use user.search. As long as we do not remove or change the shape of list, nothing breaks.

Extending Input with Optional Fields

When adding requirements, make new fields optional so old requests remain valid:

Backward compatible input
list: t.procedure
  .input(
    z.object({
      page: z.number().optional().default(1),
      filter: z.string().optional(),
    }),
  )
  .query(({ input }) => {
    // input.page selalu ada karena default; input.filter bisa undefined
    return users
      .filter((u) => !input.filter || u.nama.includes(input.filter))
      .slice((input.page - 1) * 10, input.page * 10);
  }),

Old clients that only send { page: 1 } remain valid. The optional filter does not force old clients to change. z.number().optional().default(1) provides a default value while keeping the type number.

Versioning and Procedure Deprecation

Versioning Strategies

tRPC does not provide automatic versioning — you design it. Two common approaches:

Per-procedure versioning: create a new-version procedure with a clear name when a breaking change is unavoidable:

Per-procedure versioning
user: t.router({
  // versi lama: tetap ada agar client lama tidak pecah
  detailV1: t.procedure.input(z.object({ id: z.number() })).query(ambilV1),
  // versi baru: bentuk output berbeda
  detailV2: t.procedure.input(z.object({ id: z.string() })).query(ambilV2),
}),

Namespace versioning: for big changes, wrap the new version in its own router:

Namespace versioning
export const appRouter = t.router({
  v1: v1Router,
  v2: v2Router,
});

Choose namespace when the changes are far-reaching, and per-procedure suffixes when only one or two endpoints change. The goal is the same: old clients keep running while new clients transition.

Polite Deprecation

When a procedure is replaced, mark the old one as deprecated — via documentation or a JSDoc attribute — and give a transition period:

Marking a procedure deprecated
/** @deprecated Gunakan user.byId sebagai gantinya */
byUsername: t.procedure
  .input(z.object({ username: z.string() }))
  .query(({ input }) => users.find((u) => u.username === input.username)),

The editor will show a strikethrough on user.byUsername when used, signalling the team to move away. Deprecation best practice: allow at least one release cycle before removal, and record the schedule in the changelog.

Schema Migration and Maintaining Compatibility

Separating Schema Versions

When the output shape must change drastically, keep the old procedure and add a new one — do not change the old resolver:

Migration with two outputs
const UserV1 = z.object({ id: z.number(), nama: z.string() });
const UserV2 = z.object({ id: z.string(), fullName: z.string(), email: z.string() });
 
user: t.router({
  profile: t.procedure.input(z.object({ id: z.number() })).output(UserV1).query(ambilV1),
  profileV2: t.procedure.input(z.object({ id: z.number() })).output(UserV2).query(ambilV2),
}),

With .output(UserV1) and .output(UserV2), both versions are explicitly validated and cannot pollute each other. After all clients have moved, profile can be removed in a major release.

A Gradual Removal Strategy

The safe order for removing an old procedure:

  1. Add a new version with a different name.
  2. Mark the old one @deprecated.
  3. Wait for all clients to migrate.
  4. Remove the old version in the next major version.

Throughout the process, note that tRPC is type-safe: if you remove a procedure and a client still uses it, the compiler catches it immediately — an advantage REST does not have, where breaking changes only surface at runtime.

Warning

Be careful with zod .transform() on input. Transform changes the data before the resolver — if the transformation rules change, clients sending old data could receive different results. Keep the transform inside a new procedure when its behavior changes.

Conclusion

Episode 9 teaches disciplined API evolution: adding without breaking, planned versioning, deprecation that gives clear signals, and schema migration that maintains compatibility until all clients have moved.

Key takeaways:

  • Adding new fields and procedures is always backward compatible.
  • New fields on input must be optional so old clients remain valid.
  • Per-procedure or per-namespace versioning depending on the scale of change.
  • Mark obsolete procedures with @deprecated and give a transition period.
  • Keep old resolvers; add new versions with separate outputs.
  • The type-safe compiler catches remaining procedure usage before removal.

In the next episode, episode 10, we will discuss transport, HTTP, and WebSocket — the choice of httpBatchLink, httpLink, and wsLink, the differences between batching and standard HTTP, as well as proxy, CORS, and request headers configuration.