Skip to content

Commit d1213a1

Browse files
committed
fix: resolve unrepresentable type references via type checker fallback
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).
1 parent 16036c4 commit d1213a1

6 files changed

Lines changed: 189 additions & 5 deletions

File tree

package-lock.json

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@
8888
"tsx": "^4.21.0",
8989
"typescript-eslint": "^8.57.2",
9090
"vega": "^6.2.0",
91-
"vega-lite": "^6.4.2"
91+
"vega-lite": "^6.4.2",
92+
"zod": "^4.4.3"
9293
},
9394
"packageManager": "npm@11.12.1",
9495
"publishConfig": {

src/NodeParser/TypeReferenceNodeParser.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ArrayType } from "../Type/ArrayType.js";
77
import type { BaseType } from "../Type/BaseType.js";
88
import { StringType } from "../Type/StringType.js";
99
import { UnknownType } from "../Type/UnknownType.js";
10+
import { UnhandledError } from "../Error/Errors.js";
1011
import { symbolAtNode } from "../Utils/symbolAtNode.js";
1112

1213
const invalidTypes: Record<number, boolean> = {
@@ -42,7 +43,7 @@ export class TypeReferenceNodeParser implements SubNodeParser {
4243
return new AnyType();
4344
}
4445

45-
return this.childNodeParser.createType(declaration, this.createSubContext(node, context));
46+
return this.createTypeFromDeclaration(declaration, node, context);
4647
}
4748

4849
if (typeSymbol.flags & ts.SymbolFlags.TypeParameter) {
@@ -77,12 +78,71 @@ export class TypeReferenceNodeParser implements SubNodeParser {
7778
return new AnnotatedType(new StringType(), { format: "uri" }, false);
7879
}
7980

80-
return this.childNodeParser.createType(
81+
return this.createTypeFromDeclaration(
8182
typeSymbol.declarations!.filter((n: ts.Declaration) => !invalidTypes[n.kind])[0],
82-
this.createSubContext(node, context),
83+
node,
84+
context,
8385
);
8486
}
8587

88+
/**
89+
* Resolves a referenced declaration through the child parser, falling back to the
90+
* type checker's already-resolved type when structural re-parsing crashes.
91+
*
92+
* Re-parsing the AST can drive the parser into type-level machinery of third-party
93+
* libraries (e.g. `zod`'s `z.infer<typeof schema>` conditional types, whose return
94+
* type is built from deep generics) that cannot be statically re-derived. When that
95+
* happens an unexpected (non-controlled) error is thrown; in that case we delegate to
96+
* the type checker, which has already resolved such references to a concrete type.
97+
*/
98+
protected createTypeFromDeclaration(
99+
declaration: ts.Declaration,
100+
node: ts.TypeReferenceNode,
101+
context: Context,
102+
): BaseType {
103+
try {
104+
return this.childNodeParser.createType(declaration, this.createSubContext(node, context));
105+
} catch (error) {
106+
if (error instanceof UnhandledError) {
107+
const resolved = this.createTypeFromChecker(node, context);
108+
if (resolved) {
109+
return resolved;
110+
}
111+
}
112+
throw error;
113+
}
114+
}
115+
116+
/**
117+
* Builds a base type from the type checker's fully-resolved type for the given
118+
* reference node. Returns undefined when the checker cannot offer a trustworthy
119+
* concrete type (e.g. it collapses to `any`/`unknown`), so callers can rethrow the
120+
* original error instead of silently substituting a meaningless schema.
121+
*/
122+
protected createTypeFromChecker(node: ts.TypeReferenceNode, context: Context): BaseType | undefined {
123+
let resolvedType: ts.Type;
124+
try {
125+
resolvedType = this.typeChecker.getTypeFromTypeNode(node);
126+
} catch {
127+
return undefined;
128+
}
129+
130+
const typeNode = this.typeChecker.typeToTypeNode(resolvedType, node, ts.NodeBuilderFlags.IgnoreErrors);
131+
132+
if (!typeNode || typeNode.kind === ts.SyntaxKind.AnyKeyword || typeNode.kind === ts.SyntaxKind.UnknownKeyword) {
133+
return undefined;
134+
}
135+
try {
136+
const result = this.childNodeParser.createType(typeNode, context);
137+
if (result instanceof AnyType || result instanceof UnknownType) {
138+
return undefined;
139+
}
140+
return result;
141+
} catch {
142+
return undefined;
143+
}
144+
}
145+
86146
protected createSubContext(node: ts.TypeReferenceNode, parentContext: Context): Context {
87147
const subContext = new Context(node);
88148

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { assertValidSchema } from "../../utils";
2+
import { test } from "node:test";
3+
4+
test("valid-data - type-typeof-zod-infer", assertValidSchema("type-typeof-zod-infer", "MyObject"));
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as zod from "zod";
2+
3+
// Regression test for resolving types derived via zod's `z.infer<typeof schema>`.
4+
// The schema builder's return type is built from deep generics that the generator
5+
// cannot re-derive by statically re-parsing the AST. The type checker, however,
6+
// resolves `z.infer<...>` to a concrete type, which the generator now falls back to.
7+
// See https://github.com/vega/ts-json-schema-generator/issues/758
8+
9+
const NumberSchema = zod.number();
10+
type NumberType = zod.infer<typeof NumberSchema>;
11+
12+
const ObjectSchema = zod.object({
13+
name: zod.string(),
14+
age: zod.number().optional(),
15+
role: zod.enum(["admin", "user"]),
16+
});
17+
18+
const UnionSchema = zod.discriminatedUnion("kind", [
19+
zod.object({ kind: zod.literal("a"), value: zod.string() }),
20+
zod.object({ kind: zod.literal("b"), count: zod.number() }),
21+
]);
22+
23+
export interface MyObject {
24+
numberField: NumberType;
25+
objectField: zod.infer<typeof ObjectSchema>;
26+
unionField: zod.infer<typeof UnionSchema>;
27+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{
2+
"$ref": "#/definitions/MyObject",
3+
"$schema": "http://json-schema.org/draft-07/schema#",
4+
"definitions": {
5+
"MyObject": {
6+
"additionalProperties": false,
7+
"properties": {
8+
"numberField": {
9+
"type": "number"
10+
},
11+
"objectField": {
12+
"additionalProperties": false,
13+
"properties": {
14+
"age": {
15+
"type": "number"
16+
},
17+
"name": {
18+
"type": "string"
19+
},
20+
"role": {
21+
"enum": [
22+
"admin",
23+
"user"
24+
],
25+
"type": "string"
26+
}
27+
},
28+
"required": [
29+
"name",
30+
"role"
31+
],
32+
"type": "object"
33+
},
34+
"unionField": {
35+
"anyOf": [
36+
{
37+
"additionalProperties": false,
38+
"properties": {
39+
"kind": {
40+
"const": "a",
41+
"type": "string"
42+
},
43+
"value": {
44+
"type": "string"
45+
}
46+
},
47+
"required": [
48+
"kind",
49+
"value"
50+
],
51+
"type": "object"
52+
},
53+
{
54+
"additionalProperties": false,
55+
"properties": {
56+
"count": {
57+
"type": "number"
58+
},
59+
"kind": {
60+
"const": "b",
61+
"type": "string"
62+
}
63+
},
64+
"required": [
65+
"kind",
66+
"count"
67+
],
68+
"type": "object"
69+
}
70+
]
71+
}
72+
},
73+
"required": [
74+
"numberField",
75+
"objectField",
76+
"unionField"
77+
],
78+
"type": "object"
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)