diff --git a/packages/quicktype-typescript-input/src/index.ts b/packages/quicktype-typescript-input/src/index.ts index e93f7c0af..a2d9b1d2e 100644 --- a/packages/quicktype-typescript-input/src/index.ts +++ b/packages/quicktype-typescript-input/src/index.ts @@ -81,6 +81,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 @@ -223,6 +291,7 @@ export function schemaForTypeScriptSources( } patchGeneratorForBuiltinTypes(generator, program); + patchGeneratorForTypeAliases(generator, program); const schema = generateSchema(program, "*", settings, undefined, generator); if (schema !== null) { diff --git a/test/unit/typescript-input.test.ts b/test/unit/typescript-input.test.ts index ce7585ef5..8ffb65140 100644 --- a/test/unit/typescript-input.test.ts +++ b/test/unit/typescript-input.test.ts @@ -134,6 +134,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", + }); + }); + // https://github.com/glideapps/quicktype/issues/2695 test("strips braces from JSDoc type annotations", () => { const { schema } = schemaForSource(`