Skip to content

Commit

Permalink
feat: improve literal union type handling (#1927)
Browse files Browse the repository at this point in the history
* feat: improve literal union type handling

* Kinda working

* make it work

* Ignore hidden types

* Fix null handling

* Fix build
  • Loading branch information
domoritz committed Apr 20, 2024
1 parent c456af6 commit 4afd56e
Show file tree
Hide file tree
Showing 14 changed files with 286 additions and 134 deletions.
12 changes: 12 additions & 0 deletions src/NodeParser/IntersectionNodeParser.ts
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
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
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
@@ -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
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
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
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
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
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
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
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
@@ -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>);
};

0 comments on commit 4afd56e

Please sign in to comment.