You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf(server): make RouterClient cheaper to type-check - #2452
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.
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
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.
RouterClient delegates to RouterClientInner. The inner type identifies procedures through ~orpcProcedureDefinition 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.
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: 4
Check name
Status
Explanation
Title check
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
Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check
Check skipped because no linked issues were found for this pull request.
Description check
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.
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.
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.
⚠️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.
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. ↩
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
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 ~orpcProcedureDefinition 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.
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RouterClientused to infer every key through theProcedureclass and theLazyableunion. It now:~orpcdefinition,Lazywith its own conditional,extends Lazyable<AnyRouter>(noinfer) and recurses.The recursion lives in a local helper type,
RouterClientInner, which has noAnyRouterconstraint, because a key can be aLazyor aLazyableunion andRouterClient'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).RouterClientkeeps its signature.Measurements
Measured with the
typecheckproject of orpc-benchmarks (3,000 procedures plus a typed client call for each),tsc --singleThreaded --extendedDiagnostics, TypeScript 7.0.2. "Before" is the published2.0.0-beta.50, "after" is@orpc/serverbuilt from this branch. These counts are deterministic.Schema<I, O>typeThis is independent of #2451. With both applied, the benchmark goes from 1,040,001 to 783,432 instantiations (−24.7%).
Why
ProcedureDefinitionand notProcedureMatching
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 aDecoratedProcedure, a different class fromProcedure, 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 plainProcedure, 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 (stillnever). One edge case differs:anyrouter (RouterClient<any>, or a key typedany): 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 checkandpnpm docs:validatepass. The fullpnpm test(4,585 tests) passed on the first commit; after the later commits I re-ran the@orpc/servertests (670 pass).Lazyablekeys, an optional key,createRouterClientwith an untyped router) also pass onmain, so they describe behavior this PR keeps.🤖 Generated with Claude Code
https://claude.ai/code/session_01VVaw7SLCp5Vav859sAFS6N