Skip to content

Commit 0e3fd19

Browse files
authored
Fix/wrapper alias handling (#1876)
* test: add failing test for anonymous recursive type in wrapper Reveals bug where anonymous recursive types inside wrappers (e.g., Default<{ self: Root }, {}>) create dangling $ref because the synthetic name is created by cycle detection but never stored in definitions due to isWrapperContext check. The test shows that when an anonymous object type containing a recursive reference is wrapped in Default/Cell/Stream, the cycle detection correctly creates AnonymousType_1 and emits a $ref to it, but the definition is never stored in $defs because line 224 in schema-generator.ts prevents synthetic names from being used when isWrapperContext is true. Reported by automated PR review bot. * Bug Fixed: Anonymous recursive types in wrappers were creating dangling references. Root Cause: When a named type (like SchemaRoot) was already in progress or existed in definitions, the code was incorrectly deleting it from the definitionStack at line 188-190. This broke cycle detection for any child types being formatted, because the parent type was no longer on the stack to detect the cycle. The Fix: Removed the incorrect definitionStack.delete() call at lines 188-190. This deletion was logically wrong because: 1. We never pushed to the stack at that point (we return early before line 210's push) 2. If something with that key WAS on the stack, it was from a parent call, and deleting it broke cycle detection Test Updates: - Updated anonymous-recursive-wrapper.expected.json to reflect the correct behavior: anonymous objects that appear only once should be inlined, not hoisted to separate definitions - The test now passes with the schema correctly inlining the anonymous object and using for SchemaRoot's cycle * fix(schema-generator): handle wrapper type aliases with generic parameters Fixes improper type extraction from wrapper alias declarations that contain generic type parameters instead of concrete types. When processing wrapper aliases like 'type MyCell<T> = Cell<T>' or nested aliases like 'Cell<NumberList<number>>' where 'NumberList<T> = T[]', the code was incorrectly extracting type information from alias declarations (which contain generic 'T') instead of from usage sites (which have concrete types like 'string' or 'number'). Key changes: - Filter out type parameter nodes in wrapper and array formatters by checking if extracted types have TypeFlags.TypeParameter flag - Fix formatChildType to properly clear parent typeNode context using destructuring, preventing mismatched type/node pairs in recursive calls - Improve Default wrapper node selection to prefer usage-site nodes with concrete type arguments over alias declaration nodes - Update wrapper-aliases test fixture to use realistic interface definitions matching actual CommonTools wrappers instead of simple type erasure Before: 'Cell<NumberList<number>>' generated 'items: {}' (empty object) After: 'Cell<NumberList<number>>' generates 'items: { type: number }' Related to commit 16aceaf which fixed anonymous recursive wrapper refs. * fmt, lint * update fixture expectation for new improved behavior * fix fixture
1 parent 2fe7c01 commit 0e3fd19

File tree

8 files changed

+360
-102
lines changed

8 files changed

+360
-102
lines changed

packages/schema-generator/src/formatters/common-tools-formatter.ts

Lines changed: 84 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
TypeFormatter,
66
} from "../interface.ts";
77
import type { SchemaGenerator } from "../schema-generator.ts";
8+
import { detectWrapperViaNode, resolveWrapperNode } from "../type-utils.ts";
89

910
type WrapperKind = "Cell" | "Stream" | "OpaqueRef";
1011

@@ -24,35 +25,66 @@ export class CommonToolsFormatter implements TypeFormatter {
2425
}
2526

2627
supportsType(type: ts.Type, context: GenerationContext): boolean {
27-
// Prefer Default via node (erased at type-level)
28-
const n = context.typeNode;
29-
if (n && ts.isTypeReferenceNode(n)) {
30-
const nodeName = this.getTypeRefIdentifierName(n);
31-
if (
32-
nodeName === "Default" || this.isAliasToDefault(n, context.typeChecker)
33-
) {
34-
return true;
35-
}
28+
// Check via typeNode for Default (erased at type-level) and all wrapper aliases
29+
const wrapperViaNode = detectWrapperViaNode(
30+
context.typeNode,
31+
context.typeChecker,
32+
);
33+
if (wrapperViaNode) {
34+
return true;
3635
}
3736

38-
// Check if this is a wrapper type (Cell/Stream/OpaqueRef)
39-
return this.getWrapperTypeInfo(type) !== undefined;
37+
// Check if this is a wrapper type (Cell/Stream/OpaqueRef) via type structure
38+
const wrapperInfo = this.getWrapperTypeInfo(type);
39+
return wrapperInfo !== undefined;
4040
}
4141

4242
formatType(type: ts.Type, context: GenerationContext): SchemaDefinition {
4343
const n = context.typeNode;
4444

45+
// Check via typeNode for all wrapper types (handles both direct usage and aliases)
46+
const resolvedWrapper = n
47+
? resolveWrapperNode(n, context.typeChecker)
48+
: undefined;
49+
4550
// Handle Default via node (direct or alias)
46-
if (n && ts.isTypeReferenceNode(n)) {
47-
const isDefaultNode =
48-
(ts.isIdentifier(n.typeName) && n.typeName.text === "Default") ||
49-
this.isAliasToDefault(n, context.typeChecker);
50-
if (isDefaultNode) {
51-
return this.formatDefaultType(n, context);
51+
if (resolvedWrapper?.kind === "Default") {
52+
// For Default, we need the node with concrete type arguments.
53+
// If the original node has type arguments, use it.
54+
// Otherwise, use the resolved node (for direct Default references).
55+
const nodeForDefault = n && ts.isTypeReferenceNode(n) && n.typeArguments
56+
? n // Original has type args, use it for concrete types
57+
: resolvedWrapper.node; // Direct reference or fallback
58+
59+
if (nodeForDefault && ts.isTypeReferenceNode(nodeForDefault)) {
60+
return this.formatDefaultType(nodeForDefault, context);
61+
}
62+
}
63+
64+
// Handle Cell/Stream/OpaqueRef via node (direct or alias)
65+
if (resolvedWrapper && resolvedWrapper.kind !== "Default") {
66+
// Use the ACTUAL type from the usage site (which has concrete type arguments)
67+
const wrapperInfo = this.getWrapperTypeInfo(type);
68+
if (wrapperInfo) {
69+
// For choosing which node to pass to formatWrapperType:
70+
// - If original node has type arguments: use it (has concrete types from usage site)
71+
// - If original node is just identifier (alias): use resolved node
72+
// formatWrapperType will check if node has type args before extracting inner types
73+
const nodeToPass = n && ts.isTypeReferenceNode(n) && n.typeArguments
74+
? n // Original has type args, use it
75+
: resolvedWrapper.node; // Original is just alias, use resolved (but won't extract inner types from it)
76+
77+
return this.formatWrapperType(
78+
wrapperInfo.typeRef,
79+
nodeToPass,
80+
context,
81+
wrapperInfo.kind,
82+
);
5283
}
5384
}
5485

55-
// Get wrapper type information (Cell/Stream/OpaqueRef)
86+
// Fallback: try to get wrapper type information from type structure
87+
// (for cases where we don't have a typeNode)
5688
const wrapperInfo = this.getWrapperTypeInfo(type);
5789
if (wrapperInfo) {
5890
return this.formatWrapperType(
@@ -76,9 +108,29 @@ export class CommonToolsFormatter implements TypeFormatter {
76108
wrapperKind: WrapperKind,
77109
): SchemaDefinition {
78110
const innerTypeFromType = typeRef.typeArguments?.[0];
79-
const innerTypeNode = typeRefNode && ts.isTypeReferenceNode(typeRefNode)
80-
? typeRefNode.typeArguments?.[0]
81-
: undefined;
111+
112+
// Only extract innerTypeNode if the typeRefNode has type arguments AND
113+
// those arguments are not generic type parameters.
114+
// If typeRefNode has no type arguments, or if the arguments are generic parameters
115+
// (e.g., T from an alias declaration), we should NOT extract inner types from it.
116+
let innerTypeNode: ts.TypeNode | undefined = undefined;
117+
if (
118+
typeRefNode && ts.isTypeReferenceNode(typeRefNode) &&
119+
typeRefNode.typeArguments
120+
) {
121+
const firstArg = typeRefNode.typeArguments[0];
122+
if (firstArg) {
123+
// Check if this node represents a type parameter
124+
const argType = context.typeChecker.getTypeFromTypeNode(firstArg);
125+
const isTypeParameter =
126+
(argType.flags & ts.TypeFlags.TypeParameter) !== 0;
127+
if (!isTypeParameter) {
128+
// Not a type parameter, safe to use
129+
innerTypeNode = firstArg;
130+
}
131+
// Otherwise leave innerTypeNode as undefined (don't use type parameter nodes)
132+
}
133+
}
82134

83135
// Resolve inner type, preferring type information; fall back to node if needed
84136
let innerType: ts.Type | undefined = innerTypeFromType;
@@ -91,13 +143,23 @@ export class CommonToolsFormatter implements TypeFormatter {
91143
);
92144
}
93145

146+
// When we resolve aliases (e.g., StringCell -> Cell<string>), the resolved node's
147+
// type arguments may contain unbound generics (e.g., T) from the alias declaration.
148+
// In that case, we must NOT pass the node, since the type information has the
149+
// concrete types (e.g., string) from the usage site.
150+
// We detect this by checking if the inner type is a type parameter.
151+
const innerTypeIsGeneric =
152+
(innerType.flags & ts.TypeFlags.TypeParameter) !== 0;
153+
94154
// Don't pass synthetic TypeNodes - they lose type information (especially for arrays)
95155
// Synthetic nodes have pos === -1 and end === -1
96156
// But DO pass real TypeNodes from source code for proper type detection (e.g., Default)
97157
const isSyntheticNode = innerTypeNode && innerTypeNode.pos === -1 &&
98158
innerTypeNode.end === -1;
99159

100-
const shouldPassTypeNode = innerTypeNode && !isSyntheticNode;
160+
// Only pass typeNode if it's real (not synthetic) AND not a generic type parameter
161+
const shouldPassTypeNode = innerTypeNode && !isSyntheticNode &&
162+
!innerTypeIsGeneric;
101163
const innerSchema = this.schemaGenerator.formatChildType(
102164
innerType,
103165
context,
@@ -186,62 +248,6 @@ export class CommonToolsFormatter implements TypeFormatter {
186248
(type as ts.TypeReference).target?.symbol?.name === "Stream";
187249
}
188250

189-
private isAliasToDefault(
190-
typeNode: ts.TypeReferenceNode,
191-
typeChecker: ts.TypeChecker,
192-
): boolean {
193-
return this.followAliasChain(typeNode, typeChecker, new Set());
194-
}
195-
196-
private followAliasChain(
197-
typeNode: ts.TypeReferenceNode,
198-
typeChecker: ts.TypeChecker,
199-
visited: Set<string>,
200-
): boolean {
201-
if (!ts.isIdentifier(typeNode.typeName)) {
202-
return false;
203-
}
204-
205-
const typeName = typeNode.typeName.text;
206-
207-
// Detect circular aliases and throw descriptive error
208-
if (visited.has(typeName)) {
209-
const aliasChain = Array.from(visited).join(" -> ");
210-
throw new Error(
211-
`Circular type alias detected: ${aliasChain} -> ${typeName}`,
212-
);
213-
}
214-
visited.add(typeName);
215-
216-
// Check if we've reached "Default"
217-
if (typeName === "Default") {
218-
return true;
219-
}
220-
221-
// Look up the symbol for this type name
222-
const symbol = typeChecker.getSymbolAtLocation(typeNode.typeName);
223-
if (!symbol || !(symbol.flags & ts.SymbolFlags.TypeAlias)) {
224-
return false;
225-
}
226-
227-
const aliasDeclaration = symbol.valueDeclaration ||
228-
symbol.declarations?.[0];
229-
if (!aliasDeclaration || !ts.isTypeAliasDeclaration(aliasDeclaration)) {
230-
return false;
231-
}
232-
233-
const aliasedType = aliasDeclaration.type;
234-
if (
235-
ts.isTypeReferenceNode(aliasedType) &&
236-
ts.isIdentifier(aliasedType.typeName)
237-
) {
238-
// Recursively follow the alias chain
239-
return this.followAliasChain(aliasedType, typeChecker, visited);
240-
}
241-
242-
return false;
243-
}
244-
245251
private formatDefaultType(
246252
typeRefNode: ts.TypeReferenceNode,
247253
context: GenerationContext,

packages/schema-generator/src/schema-generator.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { CommonToolsFormatter } from "./formatters/common-tools-formatter.ts";
1414
import { UnionFormatter } from "./formatters/union-formatter.ts";
1515
import { IntersectionFormatter } from "./formatters/intersection-formatter.ts";
1616
import {
17+
detectWrapperViaNode,
1718
getNamedTypeKey,
1819
isDefaultTypeRef,
1920
safeGetIndexTypeOfType,
@@ -85,7 +86,11 @@ export class SchemaGenerator implements ISchemaGenerator {
8586
context: GenerationContext,
8687
typeNode?: ts.TypeNode,
8788
): SchemaDefinition {
88-
const childContext = typeNode ? { ...context, typeNode } : context;
89+
// IMPORTANT: Always create a new context, replacing typeNode (even if undefined).
90+
// If we pass the parent context as-is when typeNode is undefined, the child will
91+
// inherit the parent's typeNode which leads to mismatched type/node pairs.
92+
const { typeNode: _, ...baseContext } = context;
93+
const childContext = typeNode ? { ...context, typeNode } : baseContext;
8994
return this.formatType(type, childContext, false);
9095
}
9196

@@ -160,13 +165,12 @@ export class SchemaGenerator implements ISchemaGenerator {
160165
// Check if we're in a wrapper context (Default/Cell/Stream/OpaqueRef).
161166
// Wrapper types erase to their inner type, so we must check typeNode to
162167
// distinguish wrapper context from inner context.
163-
const typeNodeName =
164-
context.typeNode && ts.isTypeReferenceNode(context.typeNode) &&
165-
ts.isIdentifier(context.typeNode.typeName)
166-
? context.typeNode.typeName.text
167-
: undefined;
168-
const isWrapperContext = typeNodeName &&
169-
["Default", "Cell", "Stream", "OpaqueRef"].includes(typeNodeName);
168+
// This now handles both direct wrappers and aliases (e.g., type MyDefault<T> = Default<T, T>)
169+
const wrapperKind = detectWrapperViaNode(
170+
context.typeNode,
171+
context.typeChecker,
172+
);
173+
const isWrapperContext = wrapperKind !== undefined;
170174

171175
let namedKey = getNamedTypeKey(type, context.typeNode);
172176

@@ -175,15 +179,14 @@ export class SchemaGenerator implements ISchemaGenerator {
175179
const synthetic = context.anonymousNames.get(type);
176180
if (synthetic) namedKey = synthetic;
177181
}
182+
183+
// Check if this type is already being built or exists
178184
if (namedKey) {
179185
if (
180186
context.inProgressNames.has(namedKey) || context.definitions[namedKey]
181187
) {
182188
// Already being built or exists: emit a ref
183189
context.emittedRefs.add(namedKey);
184-
context.definitionStack.delete(
185-
this.createStackKey(type, context.typeNode, context.typeChecker),
186-
);
187190
return { "$ref": `#/$defs/${namedKey}` };
188191
}
189192
// Start building this named type; we'll store the result below

0 commit comments

Comments
 (0)