Skip to content

CLI: infer AppRouter output type from resolver return type when decorator output is omitted#107

Open
eugenepro2 wants to merge 13 commits into
KevinEdry:mainfrom
eugenepro2:feature/infer-procedure-output
Open

CLI: infer AppRouter output type from resolver return type when decorator output is omitted#107
eugenepro2 wants to merge 13 commits into
KevinEdry:mainfrom
eugenepro2:feature/infer-procedure-output

Conversation

@eugenepro2
Copy link
Copy Markdown

Summary

This PR improves the nestjs-trpc CLI DX:
when @Query/@Mutation/@Subscription does not provide output, generated AppRouter output types are now inferred from the resolver method return type (similar to native tRPC behavior).

Motivation

#106

Implementation details

1. Explicit output keeps highest priority

If a procedure has explicit decorator output:

  • runtime .output(...) stays unchanged;
  • placeholder resolver typing remains any (no forced return-type inference).

2. Inference when output is missing

If output is absent:

  • the generator infers output type from owner class/method metadata;
  • owner module is referenced via a typeof import("...") type alias;
  • type-level resolution supports:
    • named export branch,
    • default export branch,
    • fallback to any.

3. Fallback behavior

If owner metadata is unavailable:

  • fallback to Awaited<method_return_type> when available;
  • otherwise fallback to any.

Files changed

  • packages/nestjs-trpc/cli/src/generator/server.rs

    • updated procedure generation logic for conditional output typing.
    • added owner-module type alias generation via typeof import("...").
    • added __ResolveProcedureReturnType helper type (named/default/fallback branches).
  • packages/nestjs-trpc/cli/src/parser/procedure.rs

    • extracts and propagates:
      • method_return_type
      • owner_class_name
      • owner_file_path
  • packages/nestjs-trpc/cli/src/lib.rs

    • extends ProcedureMetadata with the new fields.
  • generation snapshots updated accordingly.

Behavior examples

  • With explicit decorator output:

    • .output(schema) remains
    • placeholder cast remains as any
  • Without decorator output:

    • placeholder cast uses inferred type via owner method return type

Validation

  • cargo fmt --manifest-path packages/nestjs-trpc/cli/Cargo.toml --all
  • cargo test --manifest-path packages/nestjs-trpc/cli/Cargo.toml -- --nocapture

All tests pass.

Backward compatibility

This is backward compatible:

  • runtime output validation behavior is unchanged;
  • explicit output behavior is unchanged;
  • only type inference for procedures without output is improved.

AI disclosure

This PR was prepared with AI assistance (Codex / GPT-5) and then manually reviewed and validated with formatting and test runs.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 27, 2026

@eugenepro2 is attempting to deploy a commit to the Kevin's Projects Team on Vercel.

A member of the Team first needs to authorize it.

@eugenepro2
Copy link
Copy Markdown
Author

@KevinEdry please review

@KevinEdry
Copy link
Copy Markdown
Owner

On it!

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nestjs-trpc-docs Ready Ready Preview, Comment May 18, 2026 9:48pm

Request Review

Copy link
Copy Markdown
Owner

@KevinEdry KevinEdry left a comment

Choose a reason for hiding this comment

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

Thanks for the contribution! The core approach here is sound — using typeof import(...) with conditional types to resolve method return types at the type level is a clever technique and should correctly give clients inferred output types when output is omitted.

The main concern I'd want addressed before merging: there's no integration test that actually runs tsc on the generated output. All existing tests verify the generated string content, but none confirm that TypeScript can actually resolve __ResolveProcedureReturnType<typeof import("..."), "ClassName", "method"> to the correct type. That's the riskiest part of the approach — if there's an edge case where the conditional type doesn't resolve as expected, users would discover it at build time rather than in CI.

A test that generates a file for a fixture router (with known return types) and runs tsc --noEmit on it would provide much stronger confidence.

See inline comments for specific concerns.

output.push('\n');
}

fn generate_owner_return_type_helper(&self) -> String {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This 24-line helper type gets emitted into every generated file that has at least one procedure without explicit output. A few concerns:

  1. Generated code bloat: If a project has multiple generated files (or even one), this is a lot of boilerplate. Consider extracting it to a shared utility type file that generated files can import.

  2. TypeScript version requirement: The { readonly [K in TOwnerName]: infer TClass } pattern (mapped type with infer in conditional extends) requires TypeScript 4.7+. This should be documented as a minimum version requirement, since users on older TS versions would get confusing errors.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

  1. Helper type extraction / code bloat
  • I moved __ResolveProcedureReturnType out of server.ts into a shared generated file: __nestjs-trpc-type-helpers.ts.
  • Generated server.ts files now use a type-only import:
    import type { __ResolveProcedureReturnType } from "./__nestjs-trpc-type-helpers".
  • The helper file is generated only when needed (i.e. when we infer output from owner methods), and removed when no longer needed.
  1. TypeScript version requirement
  • I documented the minimum requirement explicitly as TypeScript 4.5+:
  • in docs/README
  • in the generated helper file header

Validation

  • Updated unit tests, generation snapshots, and validation tests are passing.
  • I also verified compatibility with a TS version matrix: the current helper works on TS 4.5+ (and fails on lower versions), so the documented requirement is accurate.

);
}

procedure
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This fallback emits the raw method_return_type string (e.g., Awaited<Promise<FolderDto[]>>) without ensuring the referenced types are imported in the generated file.

This path is hit when output_file_path, owner_file_path, or owner_class_name is None — which includes the generate() method (passes output_file_path: None) and any edge case where the parser doesn't extract owner metadata.

If the return type references project types (like DTOs), the generated file would have an unresolved type reference and fail tsc. Consider falling back to any instead of using the raw return type string when the typeof import(...) path isn't available.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the catch. I updated the fallback behavior in ServerGenerator::generate_return_type_expression.

Before: when output_file_path / owner metadata was missing, we emitted Awaited<method_return_type> directly (for example Awaited<Promise<FooDto[]>>), which could introduce unresolved type references in generated server.ts.

Now: if we cannot resolve the owner-based typeof import(...) path, we always fall back to any.

This keeps the generated output compile-safe in generate() and parser edge cases where owner metadata is unavailable, while preserving the existing strongly-typed path (Awaited<__ResolveProcedureReturnType<...>>) when owner metadata is present.

Also updated tests accordingly:

  • renamed the fallback test to reflect the new behavior
  • assertion now expects as unknown as any when owner metadata is missing

const FNV_PRIME: u64 = 0x100000001b3;
const ALIAS_HASH_MASK: u64 = 0x00ff_ffff;

fn owner_module_alias(owner_class_name: &str, import_path: &str) -> String {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nit: The FNV-1a hash for generating unique aliases adds complexity that could be simpler. A sanitized relative path or an incrementing counter would avoid hash collisions entirely and be easier to reason about. The 24-bit hash (ALIAS_HASH_MASK = 0x00ff_ffff) has ~16M possibilities — collisions are unlikely in practice, but the approach is harder to debug when something goes wrong (the alias doesn't obviously trace back to the source file).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the feedback — I simplified the aliasing approach.

I removed the hash-based alias generation and switched to deterministic, readable aliases derived from owner_class_name + import_path (slug format), e.g.:

__ItemsRouterModule_enum_literals_items_router

To keep this collision-safe without hashes, aliases now use a deterministic numeric suffix when needed (_2, _3, ...).
I also wired alias lookup through generation so the imported type alias and __ResolveProcedureReturnType<...> always reference the same resolved alias.

Additionally:

  • added/updated unit coverage for readability and collision handling,
  • normalized snapshot alias path fragments for portability across machines/CI,
  • updated affected snapshots.

}

fn extract_method_return_type(
method: &swc_ecma_ast::ClassMethod,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This function has no dedicated unit tests — it's only indirectly tested through generator-level tests. Worth adding parser-level tests covering:

  • Methods with explicit return type annotations (Promise<Foo[]>, string, etc.)
  • Methods with no return type annotation (should return None)
  • Generic/complex return types (Promise<Map<string, Foo>>, union types)
  • void return type

This would catch regressions in the extraction logic independent of the generator.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the suggestion — I added dedicated parser-level coverage for extract_method_return_type.

I introduced a focused unit test (test_extract_method_return_type_variants) that validates:

  • explicit return type annotations (Promise<Foo[]>, string)
  • missing return type annotation (None)
  • complex/generic return types (Promise<Map<string, Foo>>)
  • union return types (Foo | Bar)
  • void return type

To keep the test isolated from generator behavior, it calls extract_method_return_type directly via small parser helpers (parse_source + find_class_method). This should catch regressions in extraction logic much earlier and more precisely.

}
}

fn collect_owner_imports(
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This file grows to ~570 lines with these changes, well beyond the project's 200-300 line guideline. The new owner inference functions (collect_owner_imports, append_owner_module_type_aliases, generate_owner_return_type_helper, calculate_relative_import_path, owner_module_alias) form a cohesive concern that could be extracted into a separate owner_inference.rs module. This would keep server.rs focused on router generation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the suggestion — addressed in this update.

I extracted the owner inference concern from server.rs into a dedicated owner_inference.rs module.
The extracted logic includes owner import collection, owner module alias generation, relative import path resolution, helper import generation, and helper file content generation.

server.rs now only orchestrates these calls and stays focused on router generation.
I also kept the existing ServerGenerator public API intact (including OWNER_RETURN_TYPE_HELPER_FILE_NAME and helper-related methods) by delegating internally, so there are no behavioral/API changes for consumers.

Owner-inference-specific tests were moved to the new module.


#[must_use]
pub fn generate_app_router(&self, routers: &[RouterMetadata]) -> String {
pub fn generate_app_router(
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

output_file_path is threaded through 4 function calls (generate_app_routergenerate_merged_router_stringgenerate_procedure_stringgenerate_return_type_expression). This parameter threading suggests it should be part of the generator's state — either as a field on ServerGenerator or via a generation context struct. This would simplify the signatures and avoid the None vs Some split between generate() and generate_with_schema_imports().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the feedback — great callout on the parameter threading.

I refactored the server generator to move generation context into a dedicated render session instead of passing output_file_path (and alias lookup) through multiple layers.

What changed:

  • Introduced a private ServerRenderSession that holds:
    • output_file_path
    • owner_alias_lookup
  • Moved router/procedure/return-type rendering flow to session methods.
  • Removed context threading from internal call chains (app_router -> merged_router -> procedure -> return_type).
  • Simplified public generator APIs by removing context params where they were only being forwarded.
  • generate() now renders with a session without path context.
  • generate_with_schema_imports() renders with a session that includes path + owner alias context.

Result:

  • No behavioral change intended, but signatures are cleaner and responsibilities are clearer.
  • The None vs Some(output_file_path) split is now handled at session creation boundary, not across the whole call chain.

}

let resolver_cast = if procedure.output_schema.is_some() {
"as any".to_string()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Minor: when generate_return_type_expression falls back to "any", the generated cast becomes as unknown as any — functionally identical to the previous as any but more verbose. Consider short-circuiting: if the expression is "any", just use "as any" directly to keep the generated output clean.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the suggestion — fixed.

I refactored the cast generation to avoid emitting as unknown as any in fallback cases.

What changed:

  • generate_return_type_expression now returns Option<String> instead of using "any" as a sentinel.
  • The generator now emits:
    • as any when output_schema is present
    • as unknown as <inferred type> only when a concrete return type is actually inferred
    • as any when inference is unavailable (None)

So generated output is cleaner, with no behavioral change.

Copy link
Copy Markdown
Owner

@KevinEdry KevinEdry left a comment

Choose a reason for hiding this comment

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

Review Summary

Thanks for the contribution — the core approach (using typeof import(...) + conditional types to infer procedure output from resolver return types) is sound and solves the problem described in #106.

Requesting changes for two main reasons:

  1. Missing tsc integration test — All tests verify the generated string content, but none confirm TypeScript can actually resolve __ResolveProcedureReturnType<typeof import("..."), "ClassName", "method"> to the correct type. This is the riskiest part of the approach. A test that generates output for a fixture router and runs tsc --noEmit would provide the confidence needed to merge.

  2. Architectural cleanupserver.rs grows to ~570 lines (project guideline: 200-300). The owner inference functions form a cohesive concern that should be extracted into a separate module. Additionally, output_file_path is threaded through 4 function calls — it should be part of the generator's state.

Additional items (see inline comments):

  • Fallback path emits raw type strings that may reference unimported types
  • __ResolveProcedureReturnType helper bloat + undocumented TS 4.7+ requirement
  • FNV hash over-engineering for module aliases
  • No parser-level tests for extract_method_return_type
  • as unknown as any cosmetic regression

Score: 62/100 — Solid feature, needs test coverage and structural cleanup before merge.

Human Kevin here:
I did run through each comment to validate it, after we are done updating it, i'll make a last manual review through the code to verify that the code is functional and organized.

The Rust CLI is the most critical part of the system, thats why reviews there are strict.

@eugenepro2 eugenepro2 requested a review from KevinEdry March 1, 2026 20:51
@eugenepro2
Copy link
Copy Markdown
Author

Thank you for the feedback and for reviewing my PR so thoroughly.

I’ve implemented all the requested changes and verified that everything works correctly after the updates.

Could you please take another look at the latest changes?

@eugenepro2
Copy link
Copy Markdown
Author

@KevinEdry could you review please?)

@KevinEdry
Copy link
Copy Markdown
Owner

@KevinEdry could you review please?)

I haven't forgot about this, its one of the most stressful weeks at my work, I'll try to squeeze this as soon as I can.

Comment thread packages/nestjs-trpc/cli/tests/generation.rs Outdated
Comment thread packages/nestjs-trpc/cli/src/lib.rs Outdated
Comment thread packages/nestjs-trpc/cli/src/generator/router_inference.rs
@@ -283,3 +339,23 @@ fn generation_without_transformer_has_plain_create() {
"Without transformer, output should not mention 'transformer'"
);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The primary blocker from the previous review remains unaddressed: there is still no tsc integration test that runs tsc --noEmit on the generated output.

The current tests only verify generated string content via snapshots, but do not confirm TypeScript can actually resolve __ResolveProcedureReturnType<typeof import("..."), "ClassName", "method"> to the correct type. This is the riskiest part of the approach — if there is an edge case where the conditional type does not resolve as expected, users discover it at build time rather than in CI.

The infrastructure already exists (run_tsc_validation in validation.rs). Please wire in a test that generates output for a fixture router with known return types and runs tsc --noEmit on it.

@@ -0,0 +1,330 @@
use crate::RouterMetadata;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nit: The "owner" terminology (owner_class_name, owner_file_path, OwnerAliasLookup) requires context to understand. In the NestJS/tRPC domain, "router" is the established term for the class containing procedures. Consider router_class_name / router_file_path for clearer domain alignment.

Minor nit — the naming is at least internally consistent.

Copy link
Copy Markdown
Owner

@KevinEdry KevinEdry left a comment

Choose a reason for hiding this comment

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

Review Summary (Round 2)

Great progress on addressing the previous review — 7 of 8 items were handled thoroughly. The ServerRenderSession pattern, owner_inference.rs extraction, and alias simplification are all clean improvements.

Requesting changes for:

  1. Missing tsc integration test (primary blocker, carried from round 1) — Snapshot tests verify the generated string, but nothing confirms TypeScript can actually resolve __ResolveProcedureReturnType<typeof import("..."), "ClassName", "method"> to the correct type. The infrastructure exists (run_tsc_validation). Please wire in a test.

  2. DRY: sanitize_import_path_for_alias duplicated verbatim (~35 lines) between owner_inference.rs and tests/generation.rs. Make it pub(crate) and import it in tests.

  3. Dead code: method_return_type is parsed on ProcedureMetadata but never consumed after the fallback change. Remove it or document its future purpose.

Additional suggestions (non-blocking):

  • calculate_relative_import_path near-duplicates StaticGenerator::calculate_relative_path — consider extracting a shared utility
  • "owner" naming is not domain-aligned — "router" would be clearer

Score: 68/100 — Solid feature with clean refactoring, needs the tsc test for merge confidence.

@eugenepro2 eugenepro2 requested a review from KevinEdry March 12, 2026 07:43
@eugenepro2
Copy link
Copy Markdown
Author

@KevinEdry Thanks for you feedback. Please review again)

@eugenepro2
Copy link
Copy Markdown
Author

@KevinEdry just a kind reminder

@eugenepro2
Copy link
Copy Markdown
Author

@KevinEdry really need this feature

@KevinEdry
Copy link
Copy Markdown
Owner

@KevinEdry really need this feature

Sorry for the delay, I think CI is still failing, once it passes I don't mind merging this.

Resolves 19 clippy errors that were failing the verify-rust-cli CI job:
relax redundant pub(crate) to pub, flatten excessive nesting via guard
clauses, replace format!+push_str with writeln!, mark eligible fns const,
and clean up minor lints in integration tests.
@eugenepro2
Copy link
Copy Markdown
Author

Hey @KevinEdry, pushed a fix — the verify-rust-cli job was failing on 19 clippy lints (-D warnings). All resolved now; cargo clippy, cargo fmt --check and the 396 tests pass locally. Looks like the workflow needs your approval to run — could you kick it off? 🙏

@KevinEdry
Copy link
Copy Markdown
Owner

Hey @KevinEdry, pushed a fix — the verify-rust-cli job was failing on 19 clippy lints (-D warnings). All resolved now; cargo clippy, cargo fmt --check and the 396 tests pass locally. Looks like the workflow needs your approval to run — could you kick it off? 🙏

tests are still failing in CI, have you tried running them locally?

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