Skip to content

fix(core): defensive symbol/type handling in MethodTransformer (#1382)#1448

Closed
wildduck2 wants to merge 3 commits into
samchon:masterfrom
wildduck2:fix/method-transformer-crash
Closed

fix(core): defensive symbol/type handling in MethodTransformer (#1382)#1448
wildduck2 wants to merge 3 commits into
samchon:masterfrom
wildduck2:fix/method-transformer-crash

Conversation

@wildduck2

@wildduck2 wildduck2 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #1382.

Summary

Two helpers in packages/core/src/transformers/MethodTransformer.ts relied on non-null assertions over values the TypeScript API documents as potentially undefined. When a method return type resolves to a symbol without declarations, the transformer crashed with:

TypeError: Cannot read properties of undefined (reading '0')
    at get_name (MethodTransformer.ts:92:41)
    at MethodTransformer.transform (MethodTransformer.ts:22:58)

Or threw:

Error on ImportAnalyzer.analyze(): invalid promise type.
    at MethodTransformer.ts:85:13

This PR makes both helpers defensive. Generation now succeeds and produces the correct output type on the repro.

Root cause

Two functions in MethodTransformer.ts assumed the TypeScript API always returns populated arrays:

  1. get_name(symbol) dereferenced symbol.getDeclarations()![0]!.parent. The TypeScript API spec says getDeclarations() can return undefined or an empty array for transient/synthesized symbols (symbols created from import() type expressions, certain utility type resolutions, and some inferred types). Hitting that path on TypeScript 5.7.x produces the TypeError above.

  2. escape_promise(type) called checker.getTypeArguments() and threw a hard error unless exactly one type argument was returned. Conditional types, already-resolved generics, and certain import() type expressions can legally return zero arguments.

The same package already uses the safe pattern in ParameterDecoratorTransformer.ts and packages/sdk/src/analyses/DtoAnalyzer.ts. This change aligns MethodTransformer with that pattern.

Fix

  • get_name(): use optional chaining, fall back to symbol.escapedName when no parent declaration is available.
  • escape_promise(): wrap the call in try/catch, return the original type when type arguments cannot be extracted or length is not 1.

Both changes preserve existing behavior on the happy path. When a symbol does have declarations and Promise has one type argument, the output is identical. The fallbacks only take over in the edge cases that previously crashed.

How the bug was narrowed down

  1. Read the existing report on @nestia/core MethodTransformer crashes on inline/utility Promise return types ("get_name" undefined / "invalid promise type") #1382 and its stack trace pointing at MethodTransformer.ts:92.

  2. Cloned the reporter's public repro (Neuron-Grid/Mycelia-API) to see the exact patterns used in the failing controllers.

  3. Built a minimal demonstration project that mirrors the pattern: @TypedRoute.Get<import("...").Envelope<import("...").Dto>>(""), with a controller return type using a type alias.

  4. Confirmed the crash on @nestia/core@8.0.5 with typia@9.7.2 and typescript@5.7.2.

  5. Added console.log to the compiled helpers in the installed @nestia/core to capture which symbol the crash was on. The log showed:

    get_name: SuccessResponse decls: NONE
    

    confirming the symbol had no declarations before the unsafe dereference.

  6. Applied the defensive fix to the installed compiled JS to verify the fix stops the crash and restores correct output type resolution (SDK then produced the correct typed Output instead of crashing).

  7. Ported the fix to the workspace source in packages/core/src/transformers/MethodTransformer.ts.

  8. On current master (typia@12, typescript@5.9.3) the crash does not reproduce even without the fix, because upstream TypeScript and typia resolve those symbols differently. The unsafe code path is still latent, so the defensive changes harden it for users on older toolchains and against future regressions.

Tests

Adds tests/test-sdk/features/method-transformer-defensive/ following the same layout as existing clone-based features. It covers:

  • inline and anonymous object return types wrapped in Promise
  • intersection of named interfaces
  • ReturnType<typeof fn> utility types
  • infer keyword in conditional types
  • generic envelope interfaces
  • type aliases over interfaces
  • import() type expressions in a @TypedRoute generic argument

Run the standard test automation:

pnpm install
npm run build
npm run test

Reproducing the original crash

The repro cannot be committed because it intentionally pins older dependencies. Use the config below in a clean directory to reproduce the original failure on the pre-fix code.

package.json:

{
  "private": true,
  "name": "nestia-1382-repro",
  "scripts": {
    "openapi:gen": "nestia swagger",
    "prepare": "ts-patch install && typia patch"
  },
  "dependencies": {
    "@nestia/core": "8.0.5",
    "@nestia/fetcher": "8.0.5",
    "@nestjs/common": "11.1.6",
    "@nestjs/core": "11.1.6",
    "@nestjs/platform-express": "11.1.6",
    "reflect-metadata": "0.2.2",
    "typia": "9.7.2"
  },
  "devDependencies": {
    "@nestia/sdk": "8.0.5",
    "@types/node": "24.3.1",
    "nestia": "8.0.5",
    "ts-node": "10.9.2",
    "ts-patch": "3.3.0",
    "tsconfig-paths": "4.2.0",
    "typescript": "5.7.2",
    "typescript-transform-paths": "3.5.6"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node",
    "target": "ES2023",
    "lib": ["ES2023"],
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "./dist",
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    },
    "plugins": [
      {
        "transform": "typia/lib/transform",
        "resolvePathAliases": true,
        "tsConfig": "./tsconfig.json"
      },
      { "transform": "@nestia/core/lib/transform" },
      { "transform": "@nestia/sdk/lib/transform" }
    ]
  },
  "include": [
    "src/app.controller.ts",
    "src/app.service.ts",
    "src/common/dto/greeting.dto.ts",
    "src/common/utils/response.util.ts"
  ]
}

nestia.config.ts:

import type { INestiaConfig } from "@nestia/sdk";
import "reflect-metadata";

export const NESTIA_CONFIG: INestiaConfig = {
  input: ["src/app.controller.ts"],
  output: "src/api",
  clone: true,
  swagger: {
    openapi: "3.1",
    output: "swagger.json",
  },
};
export default NESTIA_CONFIG;

src/app.service.ts:

import { Injectable } from "@nestjs/common";

@Injectable()
export class AppService {
  public getHello(): string {
    return "Hello World!";
  }
}

src/common/dto/greeting.dto.ts:

export class GreetingDto {
  greeting!: string;
}

src/common/utils/response.util.ts:

export interface ResponseEnvelope<T> {
  message: string;
  data: T | null;
}

export type SuccessResponse<T> = ResponseEnvelope<T>;

export function buildResponse<T>(
  message: string,
  data: T | null = null,
): ResponseEnvelope<T> {
  return { message, data };
}

src/app.controller.ts:

import { TypedRoute } from "@nestia/core";
import { Controller } from "@nestjs/common";

import { AppService } from "@/app.service";
import { GreetingDto } from "@/common/dto/greeting.dto";
import {
  buildResponse,
  type SuccessResponse,
} from "@/common/utils/response.util";

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @TypedRoute.Get<
    import("@/common/utils/response.util").ResponseEnvelope<
      import("@/common/dto/greeting.dto").GreetingDto
    >
  >("")
  public getHello(): SuccessResponse<GreetingDto> {
    return buildResponse("OK", { greeting: this.appService.getHello() });
  }
}

Commands:

pnpm install --ignore-workspace
pnpm run openapi:gen

Without the fix this crashes with the TypeError above. With the fix swagger generation completes and the SDK Output type resolves to the expected envelope type.

Notes on docs

CONTRIBUTING.md already states that new code should ship with a test demonstration project. The test under tests/test-sdk/features/method-transformer-defensive/ satisfies that requirement. No additional doc updates appear to be required for this change. If a CHANGELOG entry or similar is wanted, happy to add one on request.

Fixes samchon#1382. The get_name() and escape_promise() helpers used
non-null assertions on values that the TypeScript API documents as
potentially undefined. When a method return type resolves to a symbol
without declarations (e.g. via import() type expressions on older TS
versions), these helpers crashed with:

  TypeError: Cannot read properties of undefined (reading '0')

or threw:

  Error on ImportAnalyzer.analyze(): invalid promise type.

Replace the unsafe assertions with optional chaining and safe
fallbacks, matching the pattern already used in
ParameterDecoratorTransformer.ts:132-134 and DtoAnalyzer.ts:248-251
within the same codebase.
Adds a new test feature that exercises the symbol and type paths
previously guarded by non-null assertions in MethodTransformer.
Covers:

- inline/anonymous object return types wrapped in Promise
- intersection of named interfaces
- ReturnType<typeof fn> utility types
- infer keyword in conditional types
- generic envelope types
- type aliases over interfaces
- import() type expressions in TypedRoute generic arguments

Follows the structure of existing clone-based features under
tests/test-sdk/features/ and is gitignored for generated output.
@wildduck2

wildduck2 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor Author

Note on the investigation path and why the PR description is long.

The issue had two reports (the original bug and sergio-milu's follow-up, both unanswered), so I wanted to leave a clear audit trail in the description itself. If anything is over-explained please say so and I will trim it down in a follow-up commit.

The fix itself is small and self-contained in MethodTransformer.ts. The test feature under tests/test-sdk/features/method-transformer-defensive/ is the only other change and it follows the structure of the existing clone-based features.

@wildduck2

Copy link
Copy Markdown
Contributor Author

Closing to move this to my own fork first while I finalize things. Will reopen when ready.

@wildduck2 wildduck2 closed this Apr 12, 2026
@wildduck2 wildduck2 reopened this Apr 12, 2026
@wildduck2

wildduck2 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor Author

Note on why this only reproduces on older TypeScript versions (5.7.x and similar).

The symbol returned by checker.getReturnTypeOfSignature() in the repro controller goes through an import() type expression inside the @TypedRoute generic argument. import() types are resolved lazily by TypeScript. On 5.7.x, the lazy resolution can produce a transient/synthetic symbol that has no declarations attached by the time the nestia transformer walks it, which is why symbol.getDeclarations() returns undefined and the non-null assertion blows up in get_name().

On 5.9.x, the compiler resolves those symbols differently, and the declarations are already populated by the time we look at them, so the same unsafe code path happens to work. The underlying assumption in get_name() and escape_promise() remains unsound, even though it doesn't trip up modern TS with the current repro. The defensive changes in this PR address the root cause, so users on older toolchains are covered, and future regressions in how TS materializes these symbols won't reintroduce the crash.

TLDR: on TS 5.7.x the lazy import() type resolution leaves some symbols without declarations and the unsafe [0]!.parent dereference crashes. On TS 5.9.x it happens to work, but the code is still unsafe by API contract, which is why aligning it with the already-defensive pattern in ParameterDecoratorTransformer.ts and DtoAnalyzer.ts is the right fix.

@wildduck2

wildduck2 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor Author

Coverage gap and follow-up questions

I want to be upfront about what the added test feature actually verifies and what it does not, and get your input on how far you want this PR to go.

What the current tests verify

The test feature under tests/test-sdk/features/method-transformer-defensive/ exercises a wide range of complex type patterns end-to-end: generic envelopes, type aliases, import() types in decorator generics, inline/anonymous returns, intersections, ReturnType<typeof fn>, and infer conditionals. Running the standard npm run build && npm run test against this feature confirms the transformer produces the expected typed Output for every route and does not crash.

What the tests do not verify

On the workspace TS version (5.9.3) the crash from #1382 does not reproduce, even without the fix, because symbol.getDeclarations() never returns undefined for these patterns on modern TS. That means the defensive fallback branches in get_name() and escape_promise() are never actually executed by the test suite. The tests prove the happy path still works after the change, but they cannot prove that the undefined-handling fallback is correct.

The only environment where the fallback branch actually fires is the original reporter's toolchain: @nestia/core@8.0.5 + typia@9.7.2 + typescript@5.7.2. That is outside the workspace, which uses typia@12 (requires TS 5.9+).

Suggested options

Ordered from smallest to largest change:

  1. Land this PR as defensive hardening. Treat it as fixing an unsound non-null assertion that happens to not trip on modern TS. Close @nestia/core MethodTransformer crashes on inline/utility Promise return types ("get_name" undefined / "invalid promise type") #1382 as fixed-by-hardening and tell users on older TS to upgrade. Minimum work, maximum compatibility with your existing test suite.

  2. Add a lightweight unit test for get_name and escape_promise. These are pure functions that take a ts.Symbol or ts.Type. A tiny test file can construct a stub symbol with getDeclarations: () => undefined and assert the fallback branch. No runtime test framework is strictly required, a small script under tests/test-sdk/features/.../unit.ts or a plain node --test script would cover it. This closes the coverage gap without pinning an older TS.

  3. Add a pinned-TS regression harness. A second test feature that installs typescript@5.7.2 via --ignore-workspace and verifies the same controllers generate without crashing. This is the only way to actually exercise the bug as it occurred in the wild. Downside, it adds a second TS install to tests/ which complicates CI and may conflict with typia@12.

  4. Do option 1 here and file a follow-up issue for option 2 or 3. I can open a separate issue and take the unit test on myself if you prefer this PR to stay minimal.

Let me know which direction you prefer and I will follow through.

@samchon
samchon requested a review from Copilot April 13, 2026 03:33
@samchon samchon added the enhancement New feature or request label Apr 13, 2026

Copilot AI 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.

Pull request overview

Hardens MethodTransformer against TypeScript API edge cases where symbols have no declarations and where Promise type arguments can’t be reliably extracted, preventing nestia swagger generation crashes reported in #1382.

Changes:

  • Make get_name() and escape_promise() defensive to avoid runtime crashes on transient/synthesized TypeScript symbols and atypical generic-argument extraction results.
  • Add a new test-sdk feature project (method-transformer-defensive) exercising inline/utility/conditional/import() return-type shapes.
  • Ignore generated structures output for the new feature in test-sdk .gitignore.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/core/src/transformers/MethodTransformer.ts Adds defensive guards around symbol declaration access and Promise unwrapping.
tests/test-sdk/features/method-transformer-defensive/tsconfig.json Test feature TS config mirroring other feature layouts.
tests/test-sdk/features/method-transformer-defensive/nestia.config.ts Test feature Nestia config to generate SDK/swagger for the repro shapes.
tests/test-sdk/features/method-transformer-defensive/src/controllers/InlineReturnController.ts Adds Promise-wrapped inline/intersection/ReturnType/infer return shapes.
tests/test-sdk/features/method-transformer-defensive/src/controllers/ImportTypeController.ts Adds import() type-expression and alias/envelope shapes for coverage.
tests/test-sdk/.gitignore Ignores generated structures for the new feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/transformers/MethodTransformer.ts
@wildduck2

Copy link
Copy Markdown
Contributor Author

Note: this PR only partially addresses the broader issue discussed in #1393. It hardens MethodTransformer and should cover the crashes around missing declarations / invalid promise type, but it does not yet address the separate Nx transformer-loader mismatch (program.getCompilerOptions is not a function). I will handle that part in a follow-up PR. Before patching it, I will first reproduce the remaining error reliably and then fix it against the repro.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@nestia/core MethodTransformer crashes on inline/utility Promise return types ("get_name" undefined / "invalid promise type")

3 participants