Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve literal union type handling #1927

Merged
merged 6 commits into from
Apr 20, 2024
Merged
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
12 changes: 12 additions & 0 deletions src/NodeParser/IntersectionNodeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { derefType } from "../Utils/derefType.js";
import { uniqueTypeArray } from "../Utils/uniqueTypeArray.js";
import { UndefinedType } from "../Type/UndefinedType.js";
import { NeverType } from "../Type/NeverType.js";
import { ObjectType } from "../Type/ObjectType.js";
import { StringType } from "../Type/StringType.js";

export class IntersectionNodeParser implements SubNodeParser {
public constructor(
Expand All @@ -28,10 +30,20 @@ export class IntersectionNodeParser implements SubNodeParser {
return new NeverType();
}

// handle autocomplete hacks like `string & {}`
if (types.length === 2 && types.some((t) => t instanceof StringType) && types.some((t) => isEmptyObject(t))) {
return new StringType(true);
}

return translate(types);
}
}

function isEmptyObject(x: BaseType) {
const t = derefType(x);
return t instanceof ObjectType && !t.getAdditionalProperties() && !t.getProperties().length;
}

function derefAndFlattenUnions(type: BaseType): BaseType[] {
const derefed = derefType(type);
return derefed instanceof UnionType
Expand Down
2 changes: 1 addition & 1 deletion src/NodeParser/MappedTypeNodeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export class MappedTypeNodeParser implements SubNodeParser {

protected createSubContext(
node: ts.MappedTypeNode,
key: LiteralType | StringType,
key: LiteralType | StringType | NumberType,
parentContext: Context,
): Context {
const subContext = new Context(node);
Expand Down
4 changes: 4 additions & 0 deletions src/Type/LiteralType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ export class LiteralType extends BaseType {
public getValue(): LiteralValue {
return this.value;
}

public isString(): boolean {
return typeof this.value === "string";
}
}
8 changes: 8 additions & 0 deletions src/Type/StringType.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { PrimitiveType } from "./PrimitiveType.js";

export class StringType extends PrimitiveType {
constructor(protected preserveLiterals = false) {
super();
}

public getId(): string {
return "string";
}

public getPreserveLiterals(): boolean {
return this.preserveLiterals;
}
}
4 changes: 1 addition & 3 deletions src/TypeFormatter/AnnotatedTypeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ export class AnnotatedTypeFormatter implements SubTypeFormatter {
delete annotations.discriminator;
} else {
throw new Error(
`Cannot assign discriminator tag to type: ${JSON.stringify(
derefed,
)}. This tag can only be assigned to union types.`,
`Cannot assign discriminator tag to type: ${derefed.getName()}. This tag can only be assigned to union types.`,
);
}
}
Expand Down
92 changes: 73 additions & 19 deletions src/TypeFormatter/LiteralUnionTypeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,97 @@ import { Definition } from "../Schema/Definition.js";
import { RawTypeName } from "../Schema/RawType.js";
import { SubTypeFormatter } from "../SubTypeFormatter.js";
import { BaseType } from "../Type/BaseType.js";
import { LiteralType } from "../Type/LiteralType.js";
import { LiteralType, LiteralValue } from "../Type/LiteralType.js";
import { NullType } from "../Type/NullType.js";
import { StringType } from "../Type/StringType.js";
import { UnionType } from "../Type/UnionType.js";
import { derefAliasedType, isHiddenType } from "../Utils/derefType.js";
import { typeName } from "../Utils/typeName.js";
import { uniqueArray } from "../Utils/uniqueArray.js";

export class LiteralUnionTypeFormatter implements SubTypeFormatter {
public supportsType(type: BaseType): boolean {
return type instanceof UnionType && type.getTypes().length > 0 && this.isLiteralUnion(type);
return type instanceof UnionType && type.getTypes().length > 0 && isLiteralUnion(type);
}
public getDefinition(type: UnionType): Definition {
const values = uniqueArray(type.getTypes().map((item: LiteralType | NullType) => this.getLiteralValue(item)));
const types = uniqueArray(type.getTypes().map((item: LiteralType | NullType) => this.getLiteralType(item)));
let hasString = false;
let preserveLiterals = false;
let allStrings = true;
let hasNull = false;

if (types.length === 1) {
const flattenedTypes = flattenTypes(type);

// filter out String types since we need to be more careful about them
const types = flattenedTypes.filter((t) => {
if (t instanceof StringType) {
hasString = true;
preserveLiterals = preserveLiterals || t.getPreserveLiterals();
return false;
} else if (t instanceof NullType) {
hasNull = true;
return true;
} else if (t instanceof LiteralType && !t.isString()) {
allStrings = false;
}

return true;
});

if (allStrings && hasString && !preserveLiterals) {
return {
type: types[0],
enum: values,
type: hasNull ? ["string", "null"] : "string",
};
} else {
}

const values = uniqueArray(types.map(getLiteralValue));
const typeNames = uniqueArray(types.map(getLiteralType));

const ret = {
type: typeNames.length === 1 ? typeNames[0] : typeNames,
enum: values,
};

if (preserveLiterals) {
return {
type: types,
enum: values,
anyOf: [
{
type: "string",
},
ret,
],
};
}

return ret;
}
public getChildren(type: UnionType): BaseType[] {
return [];
}
}

protected isLiteralUnion(type: UnionType): boolean {
return type.getTypes().every((item) => item instanceof LiteralType || item instanceof NullType);
}
protected getLiteralValue(value: LiteralType | NullType): string | number | boolean | null {
return value instanceof LiteralType ? value.getValue() : null;
}
protected getLiteralType(value: LiteralType | NullType): RawTypeName {
return value instanceof LiteralType ? typeName(value.getValue()) : "null";
}
function flattenTypes(type: UnionType): (StringType | LiteralType | NullType)[] {
return type
.getTypes()
.filter((t) => !isHiddenType(t))
.map(derefAliasedType)
.flatMap((t) => {
if (t instanceof UnionType) {
return flattenTypes(t);
}
return t as StringType | LiteralType | NullType;
});
}

function isLiteralUnion(type: UnionType): boolean {
return flattenTypes(type).every(
(item) => item instanceof LiteralType || item instanceof NullType || item instanceof StringType,
);
}

function getLiteralValue(value: LiteralType | NullType): LiteralValue | null {
return value instanceof LiteralType ? value.getValue() : null;
}

function getLiteralType(value: LiteralType | NullType): RawTypeName {
return value instanceof LiteralType ? typeName(value.getValue()) : "null";
}
36 changes: 1 addition & 35 deletions src/TypeFormatter/UnionTypeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ export class UnionTypeFormatter implements SubTypeFormatter {

if (undefinedIndex != -1) {
throw new Error(
`Cannot find discriminator keyword "${discriminator}" in type ${JSON.stringify(
type.getTypes()[undefinedIndex],
)}.`,
`Cannot find discriminator keyword "${discriminator}" in type ${type.getTypes()[undefinedIndex].getName()}.`,
);
}

Expand Down Expand Up @@ -98,38 +96,6 @@ export class UnionTypeFormatter implements SubTypeFormatter {

const definitions = this.getTypeDefinitions(type);

// TODO: why is this not covered by LiteralUnionTypeFormatter?
// special case for string literals | string -> string
let stringType = true;
let oneNotEnum = false;
for (const def of definitions) {
if (def.type !== "string") {
stringType = false;
break;
}
if (def.enum === undefined) {
oneNotEnum = true;
}
}
if (stringType && oneNotEnum) {
const values = [];
for (const def of definitions) {
if (def.enum) {
values.push(...def.enum);
} else if (def.const) {
values.push(def.const);
} else {
return {
type: "string",
};
}
}
return {
type: "string",
enum: values,
};
}

const flattenedDefinitions: JSONSchema7[] = [];

// Flatten anyOf inside anyOf unless the anyOf has an annotation
Expand Down
20 changes: 20 additions & 0 deletions src/Utils/derefType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { AliasType } from "../Type/AliasType.js";
import { AnnotatedType } from "../Type/AnnotatedType.js";
import { BaseType } from "../Type/BaseType.js";
import { DefinitionType } from "../Type/DefinitionType.js";
import { HiddenType } from "../Type/HiddenType.js";
import { NeverType } from "../Type/NeverType.js";
import { ReferenceType } from "../Type/ReferenceType.js";

/**
Expand All @@ -25,3 +27,21 @@ export function derefAnnotatedType(type: BaseType): BaseType {

return type;
}

export function isHiddenType(type: BaseType): boolean {
if (type instanceof HiddenType || type instanceof NeverType) {
return true;
} else if (type instanceof DefinitionType || type instanceof AliasType || type instanceof AnnotatedType) {
return isHiddenType(type.getType());
}

return false;
}

export function derefAliasedType(type: BaseType): BaseType {
if (type instanceof AliasType) {
return derefAliasedType(type.getType());
}

return type;
}
2 changes: 1 addition & 1 deletion src/Utils/typeKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function getTypeKeys(type: BaseType): LiteralType[] {
return [];
}

export function getTypeByKey(type: BaseType, index: LiteralType | StringType): BaseType | undefined {
export function getTypeByKey(type: BaseType, index: LiteralType | StringType | NumberType): BaseType | undefined {
type = derefType(type);

if (type instanceof IntersectionType || type instanceof UnionType) {
Expand Down
14 changes: 2 additions & 12 deletions test/invalid-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,14 @@ describe("invalid-data", () => {
it("duplicates", assertSchema("duplicates", "MyType", `Type "A" has multiple definitions.`));
it(
"missing-discriminator",
assertSchema(
"missing-discriminator",
"MyType",
'Cannot find discriminator keyword "type" in type ' +
'{"name":"B","type":{"id":"interface-1119825560-40-63-1119825560-0-124",' +
'"baseTypes":[],"properties":[],"additionalProperties":false,"nonPrimitive":false}}.',
),
assertSchema("missing-discriminator", "MyType", 'Cannot find discriminator keyword "type" in type B.'),
);
it(
"non-union-discriminator",
assertSchema(
"non-union-discriminator",
"MyType",
"Cannot assign discriminator tag to type: " +
'{"id":"interface-2103469249-0-76-2103469249-0-77","baseTypes":[],' +
'"properties":[{"name":"name","type":{},"required":true}],' +
'"additionalProperties":false,"nonPrimitive":false}. ' +
"This tag can only be assigned to union types.",
"Cannot assign discriminator tag to type: interface-2103469249-0-76-2103469249-0-77. This tag can only be assigned to union types.",
),
);
it(
Expand Down
1 change: 1 addition & 0 deletions test/valid-data-other.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe("valid-data-other", () => {
it("string-literals-inline", assertValidSchema("string-literals-inline", "MyObject"));
it("string-literals-intrinsic", assertValidSchema("string-literals-intrinsic", "MyObject"));
it("string-literals-null", assertValidSchema("string-literals-null", "MyObject"));
it("string-literals-hack", assertValidSchema("string-literals-hack", "MyObject"));
it("string-template-literals", assertValidSchema("string-template-literals", "MyObject"));
it("string-template-expression-literals", assertValidSchema("string-template-expression-literals", "MyObject"));
it(
Expand Down
14 changes: 14 additions & 0 deletions test/valid-data/string-literals-hack/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type Union = "a" | "b";

export type MyObject = {
literals: "foo" | "bar";
stringWithNull: string | null;
literalWithNull: "foo" | "bar" | null;
literalWithString: "foo" | "bar" | string;
literalWithStringAndNull: "foo" | "bar" | string | null;
withRef: "foo" | Union;
withRefWithString: Union | string;
withHack: "foo" | "bar" | (string & {});
withHackRecord: "foo" | "bar" | (string & Record<never, never>);
withHackNull: "foo" | "bar" | null | (string & Record<never, never>);
};
Loading
Loading