Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit cfb7033

Browse files
authored
Align generated types across targets (#3)
## Summary Align generated types across TypeScript, Dart, Kotlin, and Swift to keep unions and enums consistent, including JSON helpers for Kotlin/Dart so the models round-trip to the same payloads as the TS literal unions.
1 parent 6cbdb81 commit cfb7033

File tree

10 files changed

+3336
-350
lines changed

10 files changed

+3336
-350
lines changed

scripts/fix-generated-types.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ for (const [tsName, iosName] of iosTypeMap) {
6464
content = content.replace(pattern, iosName);
6565
}
6666

67+
// Enforce IOS capitalization conventions for enum members and fields.
68+
content = content.replace(/\b([A-Za-z0-9]+)Ios\b/g, (_, prefix) => `${prefix}IOS`);
69+
content = content.replace(/\bIos\b/g, 'IOS');
70+
71+
const toKebabCase = (value) => value
72+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
73+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
74+
.replace(/[_\s]+/g, '-')
75+
.replace(/-+/g, '-')
76+
.toLowerCase();
77+
78+
// Convert enums (except ErrorCode) to union literal types with lower-snake-case values.
79+
content = content.replace(/export enum (\w+) \{[\s\S]*?\}\n?/g, (match) => {
80+
const enumName = match.match(/export enum (\w+)/)[1];
81+
if (enumName === 'ErrorCode') return match;
82+
const valueMatches = [...match.matchAll(/=\s*'([^']+)'/g)];
83+
if (valueMatches.length === 0) return match;
84+
const literals = valueMatches.map(([, raw]) => `'${toKebabCase(raw)}'`);
85+
return `export type ${enumName} = ${literals.join(' | ')};\n`;
86+
});
87+
6788
const removeDefinition = (keyword) => {
6889
const pattern = new RegExp(`^export type ${keyword}[^]*?;\n`, 'm');
6990
if (pattern.test(content)) {

scripts/generate-dart-types.mjs

Lines changed: 228 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,17 @@ const toCamelCase = (value, upper = false) => {
7272
.filter(Boolean)
7373
.map((token) => token.toLowerCase());
7474
if (tokens.length === 0) return value;
75-
const [first, ...rest] = tokens;
76-
const firstToken = upper ? first.charAt(0).toUpperCase() + first.slice(1) : first;
77-
return [firstToken, ...rest.map((token) => token.charAt(0).toUpperCase() + token.slice(1))].join('');
75+
const normalized = tokens.map((token) => (token === 'ios' ? 'IOS' : token));
76+
const [first, ...rest] = normalized;
77+
const formatFirst = () => {
78+
if (first === 'IOS') {
79+
return upper ? 'IOS' : 'ios';
80+
}
81+
return upper ? first.charAt(0).toUpperCase() + first.slice(1) : first;
82+
};
83+
const firstToken = formatFirst();
84+
const restTokens = rest.map((token) => (token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1)));
85+
return [firstToken, ...restTokens].join('');
7886
};
7987

8088
const toPascalCase = (value) => toCamelCase(value, true);
@@ -93,20 +101,6 @@ const scalarMap = new Map([
93101
['Float', 'double'],
94102
]);
95103

96-
const getDartType = (graphqlType) => {
97-
if (graphqlType instanceof GraphQLNonNull) {
98-
const inner = getDartType(graphqlType.ofType);
99-
return { type: inner.type, nullable: false };
100-
}
101-
if (graphqlType instanceof GraphQLList) {
102-
const inner = getDartType(graphqlType.ofType);
103-
const element = inner.type + (inner.nullable ? '?' : '');
104-
return { type: `List<${element}>`, nullable: true };
105-
}
106-
const mapped = scalarMap.get(graphqlType.name) ?? graphqlType.name;
107-
return { type: mapped, nullable: true };
108-
};
109-
110104
const addDocComment = (lines, description, indent = '') => {
111105
if (!description) return;
112106
for (const docLine of description.split(/\r?\n/)) {
@@ -154,11 +148,128 @@ for (const name of typeNames) {
154148
objects.push(type);
155149
continue;
156150
}
157-
if (isInputObjectType(type)) {
151+
if (isInputObjectType(type)) {
158152
inputs.push(type);
159153
}
160154
}
161155

156+
const enumNames = new Set(enums.map((value) => value.name));
157+
const interfaceNames = new Set(interfaces.map((value) => value.name));
158+
const objectNames = new Set(objects.map((value) => value.name));
159+
const inputNames = new Set(inputs.map((value) => value.name));
160+
const unionNames = new Set(unions.map((value) => value.name));
161+
162+
const getTypeMetadata = (graphqlType) => {
163+
if (graphqlType instanceof GraphQLNonNull) {
164+
const inner = getTypeMetadata(graphqlType.ofType);
165+
return { ...inner, nullable: false };
166+
}
167+
if (graphqlType instanceof GraphQLList) {
168+
const inner = getTypeMetadata(graphqlType.ofType);
169+
const innerType = inner.dartType + (inner.nullable ? '?' : '');
170+
return {
171+
kind: 'list',
172+
nullable: true,
173+
elementType: inner,
174+
dartType: `List<${innerType}>`,
175+
};
176+
}
177+
const typeName = graphqlType.name;
178+
let kind = 'object';
179+
if (scalarMap.has(typeName)) {
180+
kind = 'scalar';
181+
} else if (enumNames.has(typeName)) {
182+
kind = 'enum';
183+
} else if (interfaceNames.has(typeName)) {
184+
kind = 'interface';
185+
} else if (inputNames.has(typeName)) {
186+
kind = 'input';
187+
} else if (unionNames.has(typeName)) {
188+
kind = 'union';
189+
} else if (objectNames.has(typeName)) {
190+
kind = 'object';
191+
}
192+
const dartType = scalarMap.get(typeName) ?? typeName;
193+
return {
194+
kind,
195+
name: typeName,
196+
nullable: true,
197+
dartType,
198+
};
199+
};
200+
201+
const getDartType = (graphqlType) => {
202+
const metadata = getTypeMetadata(graphqlType);
203+
return { type: metadata.dartType, nullable: metadata.nullable, metadata };
204+
};
205+
206+
const buildFromJsonExpression = (metadata, sourceExpression) => {
207+
if (metadata.kind === 'list') {
208+
const listCast = `(${sourceExpression} as List<dynamic>${metadata.nullable ? '?' : ''})`;
209+
const elementExpression = buildFromJsonExpression(metadata.elementType, 'e');
210+
const mapCall = (target) => `${target}.map((e) => ${elementExpression}).toList()`;
211+
if (metadata.nullable) {
212+
return `${listCast} == null ? null : ${mapCall(`${listCast}!`)}`;
213+
}
214+
return mapCall(listCast);
215+
}
216+
if (metadata.kind === 'scalar') {
217+
switch (metadata.name) {
218+
case 'Float':
219+
return metadata.nullable
220+
? `(${sourceExpression} as num?)?.toDouble()`
221+
: `(${sourceExpression} as num).toDouble()`;
222+
case 'Int':
223+
return metadata.nullable
224+
? `${sourceExpression} as int?`
225+
: `${sourceExpression} as int`;
226+
case 'Boolean':
227+
return metadata.nullable
228+
? `${sourceExpression} as bool?`
229+
: `${sourceExpression} as bool`;
230+
case 'ID':
231+
case 'String':
232+
return metadata.nullable
233+
? `${sourceExpression} as String?`
234+
: `${sourceExpression} as String`;
235+
default:
236+
return metadata.nullable ? `${sourceExpression}` : `${sourceExpression}`;
237+
}
238+
}
239+
if (metadata.kind === 'enum') {
240+
return metadata.nullable
241+
? `${sourceExpression} != null ? ${metadata.name}.fromJson(${sourceExpression} as String) : null`
242+
: `${metadata.name}.fromJson(${sourceExpression} as String)`;
243+
}
244+
if (['object', 'input', 'interface', 'union'].includes(metadata.kind)) {
245+
return metadata.nullable
246+
? `${sourceExpression} != null ? ${metadata.dartType}.fromJson(${sourceExpression} as Map<String, dynamic>) : null`
247+
: `${metadata.dartType}.fromJson(${sourceExpression} as Map<String, dynamic>)`;
248+
}
249+
return metadata.nullable ? `${sourceExpression}` : `${sourceExpression}`;
250+
};
251+
252+
const buildToJsonExpression = (metadata, accessorExpression) => {
253+
if (metadata.kind === 'list') {
254+
const inner = buildToJsonExpression(metadata.elementType, 'e');
255+
if (metadata.nullable) {
256+
return `${accessorExpression} == null ? null : ${accessorExpression}!.map((e) => ${inner}).toList()`;
257+
}
258+
return `${accessorExpression}.map((e) => ${inner}).toList()`;
259+
}
260+
if (metadata.kind === 'enum') {
261+
return metadata.nullable
262+
? `${accessorExpression}?.toJson()`
263+
: `${accessorExpression}.toJson()`;
264+
}
265+
if (['object', 'input', 'interface', 'union'].includes(metadata.kind)) {
266+
return metadata.nullable
267+
? `${accessorExpression}?.toJson()`
268+
: `${accessorExpression}.toJson()`;
269+
}
270+
return accessorExpression;
271+
};
272+
162273
const lines = [];
163274
lines.push(
164275
'// ============================================================================',
@@ -178,12 +289,37 @@ const printEnum = (enumType) => {
178289
const values = enumType.getValues();
179290
values.forEach((value, index) => {
180291
addDocComment(lines, value.description, ' ');
181-
const name = escapeDartName(toCamelCase(value.name));
292+
const name = escapeDartName(toPascalCase(value.name));
182293
const rawValue = toConstantCase(value.name);
183294
const suffix = index === values.length - 1 ? ';' : ',';
184295
lines.push(` ${name}('${rawValue}')${suffix}`);
185296
});
186-
lines.push('', ` const ${enumType.name}(this.value);`, ' final String value;', '}', '');
297+
lines.push(
298+
'',
299+
` const ${enumType.name}(this.value);`,
300+
' final String value;',
301+
'',
302+
` factory ${enumType.name}.fromJson(String value) {`,
303+
' switch (value) {'
304+
);
305+
values.forEach((value) => {
306+
const name = escapeDartName(toPascalCase(value.name));
307+
const rawValue = toConstantCase(value.name);
308+
const schemaValue = value.name;
309+
lines.push(` case '${rawValue}':`, ` return ${enumType.name}.${name};`);
310+
if (schemaValue !== rawValue) {
311+
lines.push(` case '${schemaValue}':`, ` return ${enumType.name}.${name};`);
312+
}
313+
});
314+
lines.push(
315+
' }',
316+
` throw ArgumentError('Unknown ${enumType.name} value: $value');`,
317+
' }',
318+
'',
319+
' String toJson() => value;',
320+
'}',
321+
''
322+
);
187323
};
188324

189325
const printInterface = (interfaceType) => {
@@ -203,30 +339,53 @@ const printInterface = (interfaceType) => {
203339
const printObject = (objectType) => {
204340
addDocComment(lines, objectType.description);
205341
const interfacesForObject = objectType.getInterfaces().map((iface) => iface.name);
206-
const unionInterfaces = unionMembership.has(objectType.name)
342+
const unionsForObject = unionMembership.has(objectType.name)
207343
? Array.from(unionMembership.get(objectType.name)).sort()
208344
: [];
209-
const implementsList = [...interfacesForObject, ...unionInterfaces];
210-
const implementsClause = implementsList.length ? ` implements ${implementsList.join(', ')}` : '';
211-
lines.push(`class ${objectType.name}${implementsClause} {`);
345+
const baseUnion = unionsForObject.shift() ?? null;
346+
const extendsClause = baseUnion ? ` extends ${baseUnion}` : '';
347+
const implementsTargets = [...interfacesForObject, ...unionsForObject];
348+
const implementsClause = implementsTargets.length ? ` implements ${implementsTargets.join(', ')}` : '';
349+
lines.push(`class ${objectType.name}${extendsClause}${implementsClause} {`);
212350
lines.push(` const ${objectType.name}({`);
213351
const fields = Object.values(objectType.getFields()).sort((a, b) => a.name.localeCompare(b.name));
214-
fields.forEach((field, index) => {
215-
addDocComment(lines, field.description, ' ');
216-
const { type, nullable } = getDartType(field.type);
217-
const fieldType = `${type}${nullable ? '?' : ''}`;
352+
const fieldInfos = fields.map((field) => {
353+
const { type, nullable, metadata } = getDartType(field.type);
218354
const fieldName = escapeDartName(field.name);
355+
return { field, fieldName, type, nullable, metadata };
356+
});
357+
fieldInfos.forEach(({ field, nullable, fieldName }) => {
358+
addDocComment(lines, field.description, ' ');
219359
const line = ` ${nullable ? '' : 'required '}this.${fieldName},`;
220360
lines.push(line);
221361
});
222362
lines.push(' });', '');
223-
fields.forEach((field) => {
363+
fieldInfos.forEach(({ field, type, nullable, fieldName }) => {
224364
addDocComment(lines, field.description, ' ');
225-
const { type, nullable } = getDartType(field.type);
226365
const fieldType = `${type}${nullable ? '?' : ''}`;
227-
const fieldName = escapeDartName(field.name);
228366
lines.push(` final ${fieldType} ${fieldName};`);
229367
});
368+
lines.push('');
369+
lines.push(` factory ${objectType.name}.fromJson(Map<String, dynamic> json) {`);
370+
lines.push(` return ${objectType.name}(`);
371+
fieldInfos.forEach(({ field, fieldName, metadata }) => {
372+
const jsonExpression = buildFromJsonExpression(metadata, `json['${field.name}']`);
373+
lines.push(` ${fieldName}: ${jsonExpression},`);
374+
});
375+
lines.push(' );');
376+
lines.push(' }', '');
377+
if (baseUnion) {
378+
lines.push(' @override');
379+
}
380+
lines.push(' Map<String, dynamic> toJson() {');
381+
lines.push(' return {');
382+
lines.push(` '__typename': '${objectType.name}',`);
383+
fieldInfos.forEach(({ field, fieldName, metadata }) => {
384+
const toJsonExpression = buildToJsonExpression(metadata, fieldName);
385+
lines.push(` '${field.name}': ${toJsonExpression},`);
386+
});
387+
lines.push(' };');
388+
lines.push(' }');
230389
lines.push('}', '');
231390
};
232391

@@ -235,29 +394,58 @@ const printInput = (inputType) => {
235394
lines.push(`class ${inputType.name} {`);
236395
lines.push(` const ${inputType.name}({`);
237396
const fields = Object.values(inputType.getFields()).sort((a, b) => a.name.localeCompare(b.name));
238-
fields.forEach((field) => {
239-
addDocComment(lines, field.description, ' ');
240-
const { type, nullable } = getDartType(field.type);
241-
const fieldType = `${type}${nullable ? '?' : ''}`;
397+
const fieldInfos = fields.map((field) => {
398+
const { type, nullable, metadata } = getDartType(field.type);
242399
const fieldName = escapeDartName(field.name);
400+
return { field, fieldName, type, nullable, metadata };
401+
});
402+
fieldInfos.forEach(({ field, nullable, fieldName }) => {
403+
addDocComment(lines, field.description, ' ');
243404
const line = ` ${nullable ? '' : 'required '}this.${fieldName},`;
244405
lines.push(line);
245406
});
246407
lines.push(' });', '');
247-
fields.forEach((field) => {
408+
fieldInfos.forEach(({ field, type, nullable, fieldName }) => {
248409
addDocComment(lines, field.description, ' ');
249-
const { type, nullable } = getDartType(field.type);
250410
const fieldType = `${type}${nullable ? '?' : ''}`;
251-
const fieldName = escapeDartName(field.name);
252411
lines.push(` final ${fieldType} ${fieldName};`);
253412
});
413+
lines.push('');
414+
lines.push(` factory ${inputType.name}.fromJson(Map<String, dynamic> json) {`);
415+
lines.push(` return ${inputType.name}(`);
416+
fieldInfos.forEach(({ field, fieldName, metadata }) => {
417+
const jsonExpression = buildFromJsonExpression(metadata, `json['${field.name}']`);
418+
lines.push(` ${fieldName}: ${jsonExpression},`);
419+
});
420+
lines.push(' );');
421+
lines.push(' }', '');
422+
lines.push(' Map<String, dynamic> toJson() {');
423+
lines.push(' return {');
424+
fieldInfos.forEach(({ field, fieldName, metadata }) => {
425+
const toJsonExpression = buildToJsonExpression(metadata, fieldName);
426+
lines.push(` '${field.name}': ${toJsonExpression},`);
427+
});
428+
lines.push(' };');
429+
lines.push(' }');
254430
lines.push('}', '');
255431
};
256432

257433
const printUnion = (unionType) => {
258434
addDocComment(lines, unionType.description);
259-
lines.push(`abstract class ${unionType.name} {}`);
260-
lines.push('');
435+
const members = unionType.getTypes().map((member) => member.name).sort();
436+
lines.push(`sealed class ${unionType.name} {`);
437+
lines.push(` const ${unionType.name}();`, '');
438+
lines.push(` factory ${unionType.name}.fromJson(Map<String, dynamic> json) {`);
439+
lines.push(` final typeName = json['__typename'] as String?;`);
440+
lines.push(' switch (typeName) {');
441+
members.forEach((member) => {
442+
lines.push(` case '${member}':`, ` return ${member}.fromJson(json);`);
443+
});
444+
lines.push(' }');
445+
lines.push(` throw ArgumentError('Unknown __typename for ${unionType.name}: $typeName');`);
446+
lines.push(' }', '');
447+
lines.push(' Map<String, dynamic> toJson();');
448+
lines.push('}', '');
261449
};
262450

263451
const printOperationInterface = (operationType) => {

0 commit comments

Comments
 (0)