Skip to content

perf(server): make RouterClient cheaper to type-check - #2452

Open
dinwwwh wants to merge 3 commits into
mainfrom
claude/perf-router-client-type
Open

dinwwwh wants to merge 3 commits into
mainfrom
claude/perf-router-client-type

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Summary

RouterClient used to infer every key through the Procedure class and the Lazyable union. It now:

  • matches a procedure through its ~orpc definition,
  • unwraps Lazy with its own conditional,
  • checks each remaining key with extends Lazyable<AnyRouter> (no infer) and recurses.

The recursion lives in a local helper type, RouterClientInner, which has no AnyRouter constraint, because a key can be a Lazy or a Lazyable union and RouterClient's constraint rejects those. The helper is not exported: a project that emits declarations writes out the expanded client shape, never the helper's name (checked with TypeScript 7.0.2 and 6.0.3). RouterClient keeps its signature.

Measurements

Measured with the typecheck project of orpc-benchmarks (3,000 procedures plus a typed client call for each), tsc --singleThreaded --extendedDiagnostics, TypeScript 7.0.2. "Before" is the published 2.0.0-beta.50, "after" is @orpc/server built from this branch. These counts are deterministic.

Inputs declared with Instantiations Types
The benchmark's vendored Standard Schema interface 1,040,001 → 910,989 (−12.4%) 247,841 → 238,845 (−3.6%)
Zod 4.6.7 1,982,622 → 1,853,610 (−6.5%) 365,229 → 356,233 (−2.5%)
Valibot 1.5.0 1,674,556 → 1,557,544 (−7.0%) 335,747 → 326,751 (−2.7%)
oRPC's own Schema<I, O> type 886,914 → 757,902 (−14.5%) 169,788 → 160,792 (−5.3%)

This is independent of #2451. With both applied, the benchmark goes from 1,040,001 to 783,432 instantiations (−24.7%).

Why ProcedureDefinition and not Procedure

Matching T extends Procedure<any, any, infer ..., infer ..., infer ...> also works and is simpler, but costs 39,033 more instantiations on the benchmark (950,022 instead of 910,989, so −8.7% instead of −12.4%). .handler() returns a DecoratedProcedure, a different class from Procedure, so TypeScript compares the two classes structurally for every procedure. Matching { '~orpc': ProcedureDefinition<...> } compares the same interface on both sides, by its type arguments. As a check, when .handler() is made to return a plain Procedure, the gap shrinks from 39,033 to 9,027.

Behavior

Clients are unchanged for ordinary routers, lazy routers, lazy procedures, keys typed as Lazyable<...>, and optional router keys (still never). One edge case differs:

  • any router (RouterClient<any>, or a key typed any): still "a procedure client, or a string-keyed tree of them", but the union is written differently, so it is not identical to the old type.

An earlier version of this branch mapped every key without the guard, which turned an optional router key into an optional client. Inside a lazy router that client is always a truthy proxy at runtime, so the guard is back and a test pins never.

Verification

  • pnpm check and pnpm docs:validate pass. The full pnpm test (4,585 tests) passed on the first commit; after the later commits I re-ran the @orpc/server tests (670 pass).
  • All new type tests (lazy at any depth, Lazyable keys, an optional key, createRouterClient with an untyped router) also pass on main, so they describe behavior this PR keeps.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VVaw7SLCp5Vav859sAFS6N

RouterClient inferred every key through the Procedure class and the
Lazyable union. It now matches a procedure through its `~orpc`
definition, unwraps Lazy with its own conditional and maps the remaining
keys directly.

The mapped type uses `as K` so TypeScript does not infer the router
backwards from an expected client, which is unbounded for a recursive
type; a test covers it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SLCp5Vav859sAFS6N
@pkg-pr-new

pkg-pr-new Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2452

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2452

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2452

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2452

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2452

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2452

@orpc/effect

npm i https://pkg.pr.new/@orpc/effect@2452

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2452

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2452

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2452

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2452

@orpc/mcp

npm i https://pkg.pr.new/@orpc/mcp@2452

@orpc/msw

npm i https://pkg.pr.new/@orpc/msw@2452

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2452

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2452

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2452

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2452

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2452

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2452

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2452

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2452

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2452

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2452

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2452

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2452

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2452

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2452

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2452

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2452

commit: 5f088a2

@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Explain Change →

No actionable comments were generated in the recent review.

Summary

Summary by CodeRabbit

  • Bug Fixes
    • Improved TypeScript types for router clients, including procedures and nested routers defined lazily or directly.
    • Router client types now handle optional router entries more accurately.
    • Creating a router client from an untyped router is supported, with compatibility for the general nested-client type.
    • Type information is preserved across nested router properties, helping provide more reliable editor feedback and catch type mismatches during development.

RouterClient now derives client types from procedure metadata and recursively maps lazy routers. Added type assertions cover lazy and non-lazy router shapes, optional keys, and createRouterClient with an untyped router.

Changes: Router client types

Layer / File(s) Summary
Recursive RouterClient mapping
packages/server/src/router-client.ts
RouterClient delegates to RouterClientInner. The inner type identifies procedures through ~orpc ProcedureDefinition metadata, unwraps lazy routers, and recursively maps Lazyable<AnyRouter> properties.
RouterClient type assertions
packages/server/src/router-client.test-d.ts
Type assertions cover lazy procedures, nested lazy routers, lazy-or-non-lazy keys, optional keys, and the createRouterClient result for an untyped router.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Refactor

Merge Risk: Minimal · up to 5f088

This change reworks the router client type mapping to cut type-instantiation cost. Types for ordinary, lazy and Lazyable routers are preserved, and the optional-key typing is the same as before. No concrete merge-blocking risk remains.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title names the main change: RouterClient is made cheaper to type-check, and the perf(server) prefix matches the scope. The description and objectives confirm that the change reduces type instanti…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is specific and on-topic. It explains that RouterClient now matches procedures through their ~orpc ProcedureDefinition, unwraps Lazy with its own conditional, and maps remaining keys t…
Full details: Title check

Explanation

The title names the main change: RouterClient is made cheaper to type-check, and the perf(server) prefix matches the scope. The description and objectives confirm that the change reduces type instantiations for RouterClient by matching procedures through ProcedureDefinition and unwrapping Lazy with a conditional type.

Full details: Description check

Explanation

The description is specific and on-topic. It explains that RouterClient now matches procedures through their ~orpc ProcedureDefinition, unwraps Lazy with its own conditional, and maps remaining keys through a non-exported RouterClientInner helper. It also gives benchmark measurements, the reason for the guard against optional router keys, the any-router edge case, and verification steps. This matches the changeset summaries for router-client.ts and router-client.test-d.ts.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3ccdbf25-c063-4f67-81ec-c950170468c3
Commits

Reviewing files that changed from the base of the PR and between dea4b4d and 5f088a2.

Files selected for processing: 2

  • packages/server/src/router-client.test-d.ts
  • packages/server/src/router-client.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 3 remain after this review.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
orpc 5f088a2 Commit Preview URL

Branch Preview URL
Nov 12 2026, 09:43 AM

@codspeed

codspeed Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 63.42%

2 warnings

⚠️ 5 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 1 improved benchmark
✅ 27 untouched benchmarks
⏩ 2 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ not modified (304) 1,078.8 µs 660.2 µs +63.42%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/perf-router-client-type (5f088a2) with main (da7ad7b)2

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (9ad3104) during the generation of this report, so da7ad7b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@codecov

codecov Bot commented Nov 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

A project that emits declarations writes out the expanded client shape,
never the helper's name, so the helper does not need to be exported.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SLCp5Vav859sAFS6N

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run reviewed the single commit 9bae120, a type-only rewrite of RouterClient, checked against a full-repo typecheck and a differential old-vs-new type probe.

  • Recursion moved into RouterClientInner — procedure detection now matches the ~orpc ProcedureDefinition shape, Lazy gets its own unwrap branch, and remaining keys are mapped directly, while RouterClient keeps its TRouter extends AnyRouter signature.
  • as K on the mapped branch — prevents reverse mapped-type inference when an expected client type is in scope, without losing homomorphic behavior.
  • New type tests — lazy unwrapping at depth, Lazyable keys, and createRouterClient with an untyped router.

Verification beyond the PR body: the old and new conditionals resolve to identical client types for concrete procedures, nested routers, Lazy/Lazyable keys, os.router/AugmentedRouter and ContractedRouter shapes, Router<Ctx>/AnyRouter/AnyProcedure, Record-indexed, readonly, union, and generic-wrapper routers; ? and readonly modifiers survive the as K remap; LazyDefinition and ProcedureContractDefinition cannot be mistaken for a procedure because they lack orderedMiddlewares/handler. Only the two deltas already documented in the PR body (optional router keys, any-typed routers) differ, and pnpm check's type-aware oxlint passes on both changed files.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run reviewed the delta since the previous pullfrog review: dea4b4d makes RouterClientInner module-local so it no longer becomes public API through export * from './router-client'.

  • Helper kept local — nothing outside router-client.ts references it, RouterClient still resolves through it, and createRouterClient keeps its signature.
  • Declaration emit checked — pnpm --filter @orpc/server prepack succeeds, and dist/router-client.d.ts keeps RouterClientInner as an unexported alias that the exported RouterClient references, so the helper stays off the public surface.
  • Full-repo tsc --noEmit (TypeScript 7.0.2) still passes with the change.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional comments: 1

packages/server/src/router-client.ts-28-28: 1

28-28: Major · Data Integrity & Integration · Heavy lift

Unverified finding
Verification ran but could not confirm this finding. It is shown for review, not as a verified issue.

Keep optional lazy keys consistent with the runtime client.

When a lazy router has an absent optional ping procedure, client.nested.ping is still a callable proxy. An if (client.nested.ping) check therefore passes, but the call rejects when the lazy loader finds no procedure. The new type permits this check and call. Align optional-key support with lazy resolution, or prevent this combination from being represented as an optional client. (raw.githubusercontent.com)

Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7007a911-ce7c-4230-bf92-f5a16f155bac
Commits

Reviewing files that changed from the base of the PR and between 9bae120 and dea4b4d.

Files selected for processing: 1

  • packages/server/src/router-client.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 4 remain after this review.

Mapping every key directly turned an optional router key into an optional
client. Inside a lazy router that client is always a truthy proxy at
runtime, so the type promised a check that cannot work. Each key is now
guarded with `extends Lazyable<AnyRouter>`, as before this branch, and a
test pins it.

The guard also stops TypeScript from inferring the router backwards
through the mapped type, so the `as K` clause is no longer needed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SLCp5Vav859sAFS6N

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run reviewed the delta since the previous pullfrog review: 5f088a2 restores the per-key Lazyable<AnyRouter> guard in RouterClient, so an optional router key maps to never again, and pins that with a type test.

  • Optional keys map to never again — each remaining key is now checked with T[K] extends Lazyable<AnyRouter> before recursing, restoring the pre-PR result for optional router keys instead of the ProcedureClient | undefined the guard-free version produced.
  • as K dropped from the mapped branch — with a conditional in the template, reverse mapped-type inference no longer applies, and ?/readonly modifiers still survive.
  • New type test — it('maps an optional key to never') asserts RouterClient<{ ping?: typeof router.ping }> is { ping?: never }.

Verified beyond the test suite: a differential probe against main's RouterClient (root tsc --noEmit) resolves identical types for os.router output, single procedures, Lazyable keys, optional keys, Record-indexed and readonly routers, and ContractedRouter; removing the guard makes the new test fail, so it is load-bearing. On a synthetic 300-procedure router the new type is still cheaper than main (117,086 vs 122,123 instantiations), so restoring the guard does not negate the PR's purpose.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants