Skip to content

fix: resolve unrepresentable type references (e.g. zod z.infer) via type checker fallback#2526

Open
heunghingwan wants to merge 1 commit into
vega:nextfrom
heunghingwan:fix/zod-infer-typeof
Open

fix: resolve unrepresentable type references (e.g. zod z.infer) via type checker fallback#2526
heunghingwan wants to merge 1 commit into
vega:nextfrom
heunghingwan:fix/zod-infer-typeof

Conversation

@heunghingwan

Copy link
Copy Markdown

Summary

Fixes a crash (Unhandled error while creating Base Type) when generating a
schema from types derived through third-party type-level machinery, most
notably zod's z.infer<typeof schema>. Even the minimal case below crashed:

import * as zod from "zod";
const Foo = zod.number();
type FooType = zod.infer<typeof Foo>;

export interface Example {
    foo: FooType; // -> Unhandled error while creating Base Type
}

Refs #758 (the issue was closed by #1947, but only one crash path was patched;
the generator still crashed one frame deeper — see "Root cause" below).

Problem

Any type derived via z.infer<typeof schema> (from z.number() up to
z.discriminatedUnion(...)) crashed both the CLI and the API. This was
reproducible with the simplest possible schema and was independent of schema
complexity. Hand-written unions worked because they never reach the value/
call-expression path described below.

Root cause

To resolve type FooType = z.infer<typeof Foo>, the parser chain traverses:

TypeAliasNodeParser            // FooType
  -> TypeReferenceNodeParser   // zod.infer<...>  (conditional type)
    -> TypeofNodeParser        // typeof Foo -> const Foo's initializer
      -> CallExpressionParser  // zod.number()
        -> typeToTypeNode()    // convert ZodNumber type back to an AST node
          -> (recurse)         // crash: synthesized node has no symbol

TypeofNodeParser resolves typeof Foo by re-parsing Foo's value initializer
(zod.number()) as a type. CallExpressionParser then takes the call's return
type and round-trips it through typeToTypeNode. For zod's deeply generic
return types this produces a synthesized node whose typeName has no resolvable
symbol, so TypeReferenceNodeParser throws Cannot read properties of undefined (reading 'flags') — wrapped into UnhandledError (code 109).

Crucially, the TypeScript checker already resolves the whole reference to a
concrete type
(e.g. z.infer<typeof Foo> -> number). The generator simply
never asked it, preferring to re-derive the type from the AST, which is
impossible for this kind of library-level type derivation.

#1947 only narrowed the typeArguments special-case in CallExpressionParser
so the flow no longer tripped on .types; it still fell through to
typeToTypeNode and crashed one frame deeper, so the original issue was not
actually resolved.

Solution

In TypeReferenceNodeParser, wrap structural resolution of a referenced
declaration and, only when it crashes with an unexpected error, fall back to
the type checker's already-resolved type for the reference:

  1. getTypeFromTypeNode(node) — let the checker fully resolve the reference
    (handles z.infer<...> and similar conditional/infer chains).
  2. typeToTypeNode(...) — convert the resolved type back to an AST node and
    parse that instead.

Why this is safe (no behavior change for the happy/sad paths)

  • The fallback is gated on error instanceof UnhandledError, i.e. it only
    triggers on an actual crash (a raw exception wrapped by
    ChainNodeParser). Controlled errors (UnknownNodeError, LogicError, …)
    propagate unchanged, so all existing error semantics are preserved.
  • createTypeFromChecker refuses to emit a schema when the checker cannot offer
    a trustworthy concrete type: it returns undefined (and the original
    error is rethrown) if resolution yields any/unknown or if the converted
    node fails to parse. The generator therefore never silently substitutes a
    meaningless schema for a genuinely broken reference.
  • The successful path is untouched.

Result

All previously-crashing cases now produce correct schemas, e.g.:

zod schema output
z.number() {"type":"number"}
z.object({a, b}) object with typed properties
z.object({}).strict() object, additionalProperties: false
nested z.object + z.array nested object/array
z.discriminatedUnion(...) anyOf with const discriminators
optional + z.enum(...) correct required + enum

Tests

  • New regression test test/valid-data/type-typeof-zod-infer covering
    z.number, z.object (with optional + enum), and z.discriminatedUnion.
  • Full suite: 378 pass / 0 fail (was 377).
  • tsc --noEmit and eslint clean.

Notes

  • The regression test depends on zod, added as a devDependency. A
    dependency-free test cannot reproduce this crash: a locally-declared generic
    interface is structurally resolvable, so it never produces the symbol-less
    synthesized node that only real library generics (like zod's) trigger. Happy
    to drop the devDep / rework the test if maintainers prefer.
  • Built on next (the default branch).

Resolving a type reference whose declaration drives the parser into
third-party type-level machinery (e.g. zod's `z.infer<typeof schema>`,
which is built from deep generics) crashed with "Unhandled error while
creating Base Type" because the AST could not be statically re-derived.

When structural re-parsing throws an unexpected error, fall back to the
type checker's already-resolved type for the reference
(getTypeFromTypeNode + typeToTypeNode). The fallback only accepts
concrete types and never silently substitutes `any`/`unknown`, so
controlled errors still surface and existing behavior is unchanged.

Adds a regression test (depends on zod as a devDependency).
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.

1 participant