Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/quicktype-typescript-input/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,74 @@ function tryGetMapValueType(
return typeArguments.length === 2 ? typeArguments[1] : undefined;
}

const referenceAnnotationKeys: ReadonlySet<string> = 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<string, ts.Symbol>;
};
const rootNamesBySymbol = new Map<ts.Symbol, string>();
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
Expand Down Expand Up @@ -223,6 +291,7 @@ export function schemaForTypeScriptSources(
}

patchGeneratorForBuiltinTypes(generator, program);
patchGeneratorForTypeAliases(generator, program);

const schema = generateSchema(program, "*", settings, undefined, generator);
if (schema !== null) {
Expand Down
51 changes: 51 additions & 0 deletions test/unit/typescript-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down
Loading