From 9923bde87fff6a25586a464a3595759109dcc513 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:24:35 -0400 Subject: [PATCH] fix(typescript-input): dedupe object type aliases referenced by properties (#879) Co-Authored-By: gpt-5.6-sol via pi --- .../quicktype-typescript-input/src/index.ts | 69 +++++++++++++++++++ test/unit/typescript-input.test.ts | 51 ++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/packages/quicktype-typescript-input/src/index.ts b/packages/quicktype-typescript-input/src/index.ts index e29f49118f..9fc0c0a13d 100644 --- a/packages/quicktype-typescript-input/src/index.ts +++ b/packages/quicktype-typescript-input/src/index.ts @@ -80,6 +80,74 @@ function tryGetMapValueType( return typeArguments.length === 2 ? typeArguments[1] : undefined; } +const referenceAnnotationKeys: ReadonlySet = new Set([ + "description", + "default", + "examples", + "title", +]); + +// Object-literal type aliases are anonymous object types internally, so +// typescript-json-schema inlines them at properties unless aliasRef is enabled. +// That setting adds indirection definitions, so point those properties at the +// named root definition directly instead. +function patchGeneratorForTypeAliases( + generator: JsonSchemaGenerator, + program: ts.Program, +): void { + // See the private API note in patchGeneratorForBuiltinTypes below. + const generatorInternals = generator as unknown as { + getTypeDefinition: (typ: ts.Type, ...rest: unknown[]) => Definition; + userSymbols: Record; + }; + const rootNamesBySymbol = new Map(); + for (const name of generator.getMainFileSymbols(program)) { + const symbol = generatorInternals.userSymbols[name]; + if (symbol !== undefined) { + rootNamesBySymbol.set(symbol, name); + } + } + + const originalGetTypeDefinition = + generatorInternals.getTypeDefinition.bind(generator); + generatorInternals.getTypeDefinition = (typ, ...rest) => { + const definition = originalGetTypeDefinition(typ, ...rest); + const property = rest[2]; + const referencedType = rest[3] as ts.Symbol | undefined; + if ( + property === undefined || + referencedType === undefined || + (referencedType.flags & ts.SymbolFlags.TypeAlias) === 0 || + (typ.flags & ts.TypeFlags.Object) === 0 || + ((typ as ts.ObjectType).objectFlags & ts.ObjectFlags.Anonymous) === + 0 + ) { + return definition; + } + + const isGeneric = (referencedType.getDeclarations() ?? []).some( + (declaration) => + ts.isTypeAliasDeclaration(declaration) && + declaration.typeParameters !== undefined && + declaration.typeParameters.length > 0, + ); + const rootName = rootNamesBySymbol.get(referencedType); + if (isGeneric || rootName === undefined) { + return definition; + } + + const annotations = Object.fromEntries( + Object.entries(definition).filter(([key]) => + referenceAnnotationKeys.has(key), + ), + ); + return { + $ref: `#/definitions/${encodeURIComponent(rootName)}`, + ...annotations, + }; + }; +} + // typescript-json-schema maps `Date` to a date-time string out of the box, // but it has no support for `Map`, and it structurally expands other // standard-library generics into meaningless schemas. Wrap the generator's @@ -149,6 +217,7 @@ export function schemaForTypeScriptSources( } patchGeneratorForBuiltinTypes(generator, program); + patchGeneratorForTypeAliases(generator, program); const schema = generateSchema(program, "*", settings, undefined, generator); const uris: string[] = []; diff --git a/test/unit/typescript-input.test.ts b/test/unit/typescript-input.test.ts index 2e5f6a8842..8310ac6a78 100644 --- a/test/unit/typescript-input.test.ts +++ b/test/unit/typescript-input.test.ts @@ -84,6 +84,57 @@ describe("schemaForTypeScriptSources", () => { expect(schema.definitions.Person.type).toBe("object"); }); + // https://github.com/glideapps/quicktype/issues/879 + test("object type aliases are referenced instead of duplicated inline", () => { + const { schema } = schemaForSource(` + type AuthTokens = { + accessToken: string; + refreshToken: string; + idToken: string; + }; + + class UserInfo { + username: string; + password?: string; + tokens?: AuthTokens; + } + `); + + expect(Object.keys(schema.definitions).sort()).toEqual([ + "AuthTokens", + "UserInfo", + ]); + expect(schema.definitions.UserInfo.properties.tokens).toEqual({ + $ref: "#/definitions/AuthTokens", + title: "tokens", + }); + }); + + test("class property types continue to use references", () => { + const { schema } = schemaForSource(` + class AuthTokens { + accessToken: string; + refreshToken: string; + idToken: string; + } + + class UserInfo { + username: string; + password?: string; + tokens?: AuthTokens; + } + `); + + expect(Object.keys(schema.definitions).sort()).toEqual([ + "AuthTokens", + "UserInfo", + ]); + expect(schema.definitions.UserInfo.properties.tokens).toEqual({ + $ref: "#/definitions/AuthTokens", + title: "tokens", + }); + }); + test("class property initializers become defaults", () => { const { schema } = schemaForSource(` export class Config {