Skip to content
This repository was archived by the owner on Jun 30, 2026. It is now read-only.

Commit b068e3f

Browse files
maorlegerCopilot
andcommitted
[typespec-ts] migrate model helper types into IR
Implement Strategy B for modular model helpers by adapting array, dict, and named nullable wrapper types into TSCodeModel.helperTypes and consuming that IR from codegen/models.ts instead of the legacy emitQueue patch. This keeps helper registration in the adapter, removes the dead Strategy A loop from the renderer, and leaves the extensible-enum follow-up (U1) explicitly scoped to adaptEnums. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9a21858 commit b068e3f

6 files changed

Lines changed: 392 additions & 108 deletions

File tree

packages/typespec-ts/src/codegen/models.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { Project, SourceFile } from "ts-morph";
22
import type { TSCodeModel } from "../codemodel/index.js";
33
import type { SdkContext } from "../utils/interfaces.js";
4-
import {
5-
emitQueue,
6-
flattenPropertyModelMap
7-
} from "../framework/hooks/sdkTypes.js";
4+
import { buildHelperTypeLookup } from "../tcgcadapter/helperTypes.js";
85
import {
96
addSerializationFunctions,
107
emitType,
@@ -35,7 +32,8 @@ export function emitModelFiles(
3532
sdkContext.sdkPackage.unions,
3633
sdkContext
3734
);
38-
const includedModelKeys = new Set<string>();
35+
const rawHelperLookup = buildHelperTypeLookup(sdkContext);
36+
const includedModels: Array<{ properties: any[] }> = [];
3937

4038
for (const model of codeModel.models) {
4139
const rawModel = rawModelLookup.get(
@@ -45,7 +43,7 @@ export function emitModelFiles(
4543
continue;
4644
}
4745

48-
includedModelKeys.add(getRawTypeKey(rawModel, sdkContext));
46+
includedModels.push(rawModel as { properties: any[] });
4947
const sourceFile = getOrCreateModelsFile(
5048
project,
5149
codeModel.settings.sourceRoot,
@@ -86,38 +84,33 @@ export function emitModelFiles(
8684
emitType(sdkContext, rawUnion, sourceFile);
8785
}
8886

89-
// Strategy A (B8 fix): emit array/dict serializer helpers that the serializer builders
90-
// still reference via refkey(type, "serializer"/"deserializer"). The legacy emitTypes()
91-
// in emitModels.ts handled this by walking the full emitQueue; the new filtered-IR
92-
// renderer only walks codeModel.models/enums/unions, so those helpers were never
93-
// registered with the binder — causing __PLACEHOLDER_*__ tokens to leak.
94-
//
95-
// TODO: migrate array/dict helper types into TSCodeModel IR so the renderer owns them
96-
// explicitly rather than relying on the global emitQueue side-channel.
97-
// See: .squad/decisions/inbox/dallas-models-helpers.md
98-
for (const type of emitQueue) {
99-
if (type.kind !== "array" && type.kind !== "dict") {
87+
for (const helperType of codeModel.helperTypes) {
88+
const rawHelperType = rawHelperLookup.get(helperType.id);
89+
if (!rawHelperType) {
10090
continue;
10191
}
92+
10293
const sourceFile = getOrCreateModelsFile(
10394
project,
10495
codeModel.settings.sourceRoot,
105-
getModelNamespaces(sdkContext, type)
96+
helperType.namespace
10697
);
107-
emitType(sdkContext, type, sourceFile);
98+
emitType(sdkContext, rawHelperType, sourceFile);
10899
}
109100

110-
for (const [property, baseModel] of flattenPropertyModelMap) {
111-
if (!includedModelKeys.has(getRawTypeKey(baseModel, sdkContext))) {
112-
continue;
101+
for (const rawModel of includedModels) {
102+
for (const property of rawModel.properties) {
103+
if (!property.flatten || property.type.kind !== "model") {
104+
continue;
105+
}
106+
107+
const sourceFile = getOrCreateModelsFile(
108+
project,
109+
codeModel.settings.sourceRoot,
110+
getModelNamespaces(sdkContext, property.type)
111+
);
112+
addSerializationFunctions(sdkContext, property, sourceFile);
113113
}
114-
115-
const sourceFile = getOrCreateModelsFile(
116-
project,
117-
codeModel.settings.sourceRoot,
118-
getModelNamespaces(sdkContext, property.type)
119-
);
120-
addSerializationFunctions(sdkContext, property, sourceFile);
121114
}
122115

123116
return cleanupEmptyModelFiles(project, codeModel.settings.sourceRoot);

packages/typespec-ts/src/codemodel/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,45 @@ export interface TSCodeModel {
3737
/** Named union declarations */
3838
unions: TSUnion[];
3939

40+
/** Helper wrapper types that still need legacy addDeclaration registration */
41+
helperTypes: TSHelperType[];
42+
4043
/** Generation settings derived from emitter options */
4144
settings: TSGenerationSettings;
4245
}
4346

47+
export type TSHelperTypeKind = "array" | "dict" | "nullable";
48+
49+
export interface TSHelperType {
50+
/** Stable semantic ID used to recover the raw helper type during transition */
51+
id: string;
52+
/** Helper kind — determines how the legacy renderer registers declarations */
53+
kind: TSHelperTypeKind;
54+
/** Display name for diagnostics/debugging */
55+
name: string;
56+
/** Namespace segments for file placement */
57+
namespace: string[];
58+
/** Wrapped element/value type */
59+
elementType: TSTypeReference;
60+
/** Whether this helper is a named nullable alias */
61+
isNamedAlias: boolean;
62+
}
63+
64+
export function buildHelperTypeId(
65+
helperType: Pick<
66+
TSHelperType,
67+
"kind" | "name" | "namespace" | "elementType" | "isNamedAlias"
68+
>
69+
): string {
70+
return [
71+
helperType.namespace.join("/"),
72+
helperType.kind,
73+
helperType.name,
74+
helperType.elementType,
75+
helperType.isNamedAlias ? "named" : "generated"
76+
].join(":");
77+
}
78+
4479
/** Normalized generation settings (not raw emitter options) */
4580
export interface TSGenerationSettings {
4681
flavor: "azure" | "unbranded";

packages/typespec-ts/src/tcgcadapter/adapter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
buildEnumTypes,
5858
getModelNamespaces
5959
} from "../modular/emitModels.js";
60+
import { adaptHelperTypes } from "./helperTypes.js";
6061
import {
6162
getMethodHierarchiesMap,
6263
hasDualFormatSupport,
@@ -140,8 +141,9 @@ export function adaptToCodeModel(input: AdapterInput): TSCodeModel {
140141
const models = adaptModels(sdkContext);
141142
const enums = adaptEnums(sdkContext);
142143
const unions = adaptUnions(sdkContext);
144+
const helperTypes = adaptHelperTypes(sdkContext);
143145

144-
return { clients, models, enums, unions, settings };
146+
return { clients, models, enums, unions, helperTypes, settings };
145147
}
146148

147149
/**
@@ -1201,6 +1203,9 @@ function hasModelUsage(usage: UsageFlags | undefined): boolean {
12011203
);
12021204
}
12031205

1206+
// TODO(strategy-b/U1): extensible enum filtering is a separate adaptEnums fix.
1207+
// Keep this migration scoped to helper-type registration so U1 can land in a
1208+
// focused follow-up.
12041209
function shouldAdaptEnum(
12051210
sdkContext: SdkContext,
12061211
enumType: SdkEnumType
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import type {
2+
SdkArrayType,
3+
SdkDictionaryType,
4+
SdkHttpOperation,
5+
SdkNullableType,
6+
SdkServiceMethod,
7+
SdkType
8+
} from "@azure-tools/typespec-client-generator-core";
9+
import { getTypeExpression } from "../modular/type-expressions/get-type-expression.js";
10+
import {
11+
buildHelperTypeId,
12+
type TSHelperType,
13+
type TSTypeReference
14+
} from "../codemodel/index.js";
15+
import { getAllOperationsFromClient } from "../framework/hooks/sdkTypes.js";
16+
import { getModelNamespaces } from "../modular/emitModels.js";
17+
import type { SdkContext } from "../utils/interfaces.js";
18+
19+
type RawHelperType = SdkArrayType | SdkDictionaryType | SdkNullableType;
20+
21+
interface HelperTypeEntry {
22+
helper: TSHelperType;
23+
rawType: RawHelperType;
24+
}
25+
26+
export function adaptHelperTypes(sdkContext: SdkContext): TSHelperType[] {
27+
return [...collectHelperTypeEntries(sdkContext).values()]
28+
.map((entry) => entry.helper)
29+
.sort(compareHelperTypes);
30+
}
31+
32+
export function buildHelperTypeLookup(
33+
sdkContext: SdkContext
34+
): Map<string, RawHelperType> {
35+
return new Map(
36+
[...collectHelperTypeEntries(sdkContext).values()].map((entry) => [
37+
entry.helper.id,
38+
entry.rawType
39+
])
40+
);
41+
}
42+
43+
function collectHelperTypeEntries(
44+
sdkContext: SdkContext
45+
): Map<string, HelperTypeEntry> {
46+
const entries = new Map<string, HelperTypeEntry>();
47+
const visited = new Set<SdkType>();
48+
49+
for (const model of sdkContext.sdkPackage.models) {
50+
visitTypeForHelpers(sdkContext, model, visited, entries);
51+
}
52+
53+
for (const unionType of sdkContext.sdkPackage.unions) {
54+
if (unionType.kind === "union") {
55+
visitTypeForHelpers(sdkContext, unionType, visited, entries);
56+
}
57+
}
58+
59+
for (const client of sdkContext.sdkPackage.clients) {
60+
for (const method of getAllOperationsFromClient(client)) {
61+
visitMethodForHelpers(sdkContext, method, visited, entries);
62+
}
63+
}
64+
65+
return entries;
66+
}
67+
68+
function visitMethodForHelpers(
69+
sdkContext: SdkContext,
70+
method: SdkServiceMethod<SdkHttpOperation>,
71+
visited: Set<SdkType>,
72+
entries: Map<string, HelperTypeEntry>
73+
): void {
74+
for (const parameter of method.parameters) {
75+
visitTypeForHelpers(sdkContext, parameter.type, visited, entries);
76+
}
77+
78+
visitTypeForHelpers(sdkContext, method.response.type, visited, entries);
79+
visitTypeForHelpers(
80+
sdkContext,
81+
method.operation.bodyParam?.type,
82+
visited,
83+
entries
84+
);
85+
86+
for (const exception of method.operation.exceptions) {
87+
visitTypeForHelpers(sdkContext, exception.type, visited, entries);
88+
}
89+
90+
for (const parameter of method.operation.parameters) {
91+
visitTypeForHelpers(sdkContext, parameter.type, visited, entries);
92+
}
93+
94+
for (const response of method.operation.responses) {
95+
visitTypeForHelpers(sdkContext, response.type, visited, entries);
96+
}
97+
}
98+
99+
function visitTypeForHelpers(
100+
sdkContext: SdkContext,
101+
type: SdkType | undefined,
102+
visited: Set<SdkType>,
103+
entries: Map<string, HelperTypeEntry>
104+
): void {
105+
if (!type || visited.has(type)) {
106+
return;
107+
}
108+
109+
visited.add(type);
110+
111+
switch (type.kind) {
112+
case "model":
113+
visitTypeForHelpers(
114+
sdkContext,
115+
type.additionalProperties,
116+
visited,
117+
entries
118+
);
119+
for (const property of type.properties) {
120+
visitTypeForHelpers(sdkContext, property.type, visited, entries);
121+
}
122+
for (const subtype of Object.values(type.discriminatedSubtypes ?? {})) {
123+
visitTypeForHelpers(sdkContext, subtype, visited, entries);
124+
}
125+
return;
126+
case "union":
127+
for (const variant of type.variantTypes) {
128+
visitTypeForHelpers(sdkContext, variant, visited, entries);
129+
}
130+
return;
131+
case "array":
132+
case "dict":
133+
case "nullable": {
134+
if (shouldAdaptHelperType(type)) {
135+
const helper = buildHelperType(sdkContext, type);
136+
entries.set(helper.id, { helper, rawType: type });
137+
}
138+
139+
const nestedType = type.kind === "nullable" ? type.type : type.valueType;
140+
visitTypeForHelpers(sdkContext, nestedType, visited, entries);
141+
return;
142+
}
143+
default:
144+
return;
145+
}
146+
}
147+
148+
function shouldAdaptHelperType(type: RawHelperType): boolean {
149+
return (
150+
type.kind !== "nullable" ||
151+
(Boolean(type.name) && type.isGeneratedName !== true)
152+
);
153+
}
154+
155+
function buildHelperType(
156+
sdkContext: SdkContext,
157+
type: RawHelperType
158+
): TSHelperType {
159+
const namespace = getModelNamespaces(sdkContext, type);
160+
const elementType =
161+
type.kind === "nullable"
162+
? adaptHelperTypeReference(sdkContext, type.type)
163+
: adaptHelperTypeReference(sdkContext, type.valueType);
164+
const name = getHelperTypeName(sdkContext, type);
165+
const isNamedAlias = type.kind === "nullable";
166+
167+
return {
168+
id: buildHelperTypeId({
169+
kind: type.kind,
170+
name,
171+
namespace,
172+
elementType,
173+
isNamedAlias
174+
}),
175+
kind: type.kind,
176+
name,
177+
namespace,
178+
elementType,
179+
isNamedAlias
180+
};
181+
}
182+
183+
function getHelperTypeName(
184+
sdkContext: SdkContext,
185+
type: RawHelperType
186+
): string {
187+
if (type.kind === "nullable") {
188+
return type.name;
189+
}
190+
191+
const elementType = adaptHelperTypeReference(sdkContext, type.valueType);
192+
return type.kind === "array" ? `${elementType}Array` : `${elementType}Record`;
193+
}
194+
195+
function adaptHelperTypeReference(
196+
sdkContext: SdkContext,
197+
type: SdkType
198+
): TSTypeReference {
199+
switch (type.kind) {
200+
case "model":
201+
case "enum":
202+
case "union":
203+
return type.name;
204+
case "array":
205+
return `Array<${adaptHelperTypeReference(sdkContext, type.valueType)}>`;
206+
case "dict":
207+
return `Record<string, ${adaptHelperTypeReference(sdkContext, type.valueType)}>`;
208+
case "nullable":
209+
return `${adaptHelperTypeReference(sdkContext, type.type)} | null`;
210+
case "constant":
211+
case "enumvalue":
212+
return JSON.stringify(type.value);
213+
default:
214+
if ("name" in type && typeof type.name === "string") {
215+
return type.name;
216+
}
217+
return getTypeExpression(sdkContext, type);
218+
}
219+
}
220+
221+
function compareHelperTypes(left: TSHelperType, right: TSHelperType): number {
222+
return [left.namespace.join("/"), left.kind, left.name, left.elementType]
223+
.join(":")
224+
.localeCompare(
225+
[
226+
right.namespace.join("/"),
227+
right.kind,
228+
right.name,
229+
right.elementType
230+
].join(":")
231+
);
232+
}

0 commit comments

Comments
 (0)