ts-json-schema-generator
Version:
Generate JSON schema from your Typescript sources
310 lines (277 loc) • 12.4 kB
text/typescript
import { AnyType } from "../Type/AnyType.js";
import { ArrayType } from "../Type/ArrayType.js";
import type { BaseType } from "../Type/BaseType.js";
import { EnumType } from "../Type/EnumType.js";
import { IntersectionType } from "../Type/IntersectionType.js";
import { NullType } from "../Type/NullType.js";
import type { ObjectProperty } from "../Type/ObjectType.js";
import { ObjectType } from "../Type/ObjectType.js";
import { OptionalType } from "../Type/OptionalType.js";
import { TupleType } from "../Type/TupleType.js";
import { UndefinedType } from "../Type/UndefinedType.js";
import { UnionType } from "../Type/UnionType.js";
import { UnknownType } from "../Type/UnknownType.js";
import { VoidType } from "../Type/VoidType.js";
import { derefType } from "./derefType.js";
import type { LiteralValue } from "../Type/LiteralType.js";
import { LiteralType } from "../Type/LiteralType.js";
import { StringType } from "../Type/StringType.js";
import { NumberType } from "../Type/NumberType.js";
import { BooleanType } from "../Type/BooleanType.js";
import { InferType } from "../Type/InferType.js";
import { RestType } from "../Type/RestType.js";
import { NeverType } from "../Type/NeverType.js";
/**
* Returns the combined types from the given intersection. Currently only object types are combined. Maybe more
* types needs to be combined to properly support complex intersections.
*
* @param intersection - The intersection type to combine.
* @return The combined types within the intersection.
*/
function combineIntersectingTypes(intersection: IntersectionType): BaseType[] {
const objectTypes: ObjectType[] = [];
const combined = intersection.getTypes().filter((type) => {
if (type instanceof ObjectType) {
objectTypes.push(type);
} else {
return true;
}
return false;
});
if (objectTypes.length === 1) {
combined.push(objectTypes[0]);
} else if (objectTypes.length > 1) {
combined.push(new ObjectType(`combined-objects-${intersection.getId()}`, objectTypes, [], false));
}
return combined;
}
/**
* Returns all object properties of the given type and all its base types.
*
* @param type - The type for which to return the properties. If type is not an object type or object has no properties
* Then an empty list ist returned.
* @return All object properties of the type. Empty if none.
*/
function getObjectProperties(type: BaseType): ObjectProperty[] {
type = derefType(type)!;
const properties = [];
if (type instanceof ObjectType) {
properties.push(...type.getProperties());
for (const baseType of type.getBaseTypes()) {
properties.push(...getObjectProperties(baseType));
}
}
return properties;
}
function getPrimitiveType(value: LiteralValue) {
switch (typeof value) {
case "string":
return new StringType();
case "number":
return new NumberType();
case "boolean":
return new BooleanType();
}
}
/**
* Checks if given source type is assignable to given target type.
*
* The logic of this function is heavily inspired by
* https://github.com/runem/ts-simple-type/blob/master/src/is-assignable-to-simple-type.ts
*
* @param source - The source type.
* @param target - The target type.
* @param inferMap - Optional parameter that keeps track of the inferred types.
* @param insideTypes - Optional parameter used internally to solve circular dependencies.
* @return True if source type is assignable to target type.
*/
export function isAssignableTo(
target: BaseType,
source: BaseType,
inferMap: Map<string, BaseType> = new Map(),
insideTypes: Set<BaseType> = new Set(),
): boolean {
// Dereference source and target
source = derefType(source);
target = derefType(target);
// Type "never" can be assigned to anything
if (source instanceof NeverType) {
return true;
}
// Nothing can be assigned to "never"
if (target instanceof NeverType) {
return false;
}
// Infer type can become anything
if (target instanceof InferType) {
const key = target.getName();
const infer = inferMap.get(key);
if (infer === undefined) {
inferMap.set(key, source);
} else {
inferMap.set(key, new UnionType([infer, source]));
}
return true;
}
// Check for simple type equality
if (source.getId() === target.getId()) {
return true;
}
/** Don't check types when already inside them. This solves circular dependencies. */
if (insideTypes.has(source) || insideTypes.has(target)) {
return true;
}
// Assigning from or to any-type is always possible
if (source instanceof AnyType || target instanceof AnyType) {
return true;
}
// assigning to unknown type is always possible
if (target instanceof UnknownType) {
return true;
}
// 'null', or 'undefined' can be assigned to the void
if (target instanceof VoidType) {
return source instanceof NullType || source instanceof UndefinedType;
}
// Union and enum type is assignable to target when all types in the union/enum are assignable to it
if (source instanceof UnionType || source instanceof EnumType) {
return source.getTypes().every((type) => isAssignableTo(target, type, inferMap, insideTypes));
}
// When source is an intersection type then it can be assigned to target if any of the sub types matches. Object
// types within the intersection must be combined first
if (source instanceof IntersectionType) {
return combineIntersectingTypes(source).some((type) => isAssignableTo(target, type, inferMap, insideTypes));
}
// For arrays check if item types are assignable
if (target instanceof ArrayType) {
const targetItemType = target.getItem();
if (source instanceof ArrayType) {
return isAssignableTo(targetItemType, source.getItem(), inferMap, insideTypes);
} else if (source instanceof TupleType) {
return isAssignableTo(targetItemType, new UnionType(source.getTypes()), inferMap, insideTypes);
} else {
return false;
}
}
// When target is a union or enum type then check if source type can be assigned to any variant
if (target instanceof UnionType || target instanceof EnumType) {
return target.getTypes().some((type) => isAssignableTo(type, source, inferMap, insideTypes));
}
// When target is an intersection type then source can be assigned to it if it matches all sub types. Object
// types within the intersection must be combined first
if (target instanceof IntersectionType) {
return combineIntersectingTypes(target).every((type) => isAssignableTo(type, source, inferMap, insideTypes));
}
// Check literal types
if (source instanceof LiteralType) {
return isAssignableTo(target, getPrimitiveType(source.getValue()), inferMap);
}
if (target instanceof ObjectType) {
// primitives are not assignable to `object`
if (
target.getNonPrimitive() &&
(source instanceof NumberType || source instanceof StringType || source instanceof BooleanType)
) {
return false;
}
const targetMembers = getObjectProperties(target);
if (targetMembers.length === 0) {
// When target object is empty then anything except null and undefined can be assigned to it
return !isAssignableTo(new UnionType([new UndefinedType(), new NullType()]), source, inferMap, insideTypes);
} else if (source instanceof ObjectType) {
const sourceMembers = getObjectProperties(source);
// Check if target has properties in common with source
const inCommon = targetMembers.some((targetMember) =>
sourceMembers.some((sourceMember) => targetMember.getName() === sourceMember.getName()),
);
return (
targetMembers.every((targetMember) => {
// Make sure that every required property in target type is present
const sourceMember = sourceMembers.find((member) => targetMember.getName() === member.getName());
return sourceMember == null ? inCommon && !targetMember.isRequired() : true;
}) &&
sourceMembers.every((sourceMember) => {
const targetMember = targetMembers.find((member) => member.getName() === sourceMember.getName());
if (targetMember == null) {
return true;
}
return isAssignableTo(
targetMember.getType(),
sourceMember.getType(),
inferMap,
new Set(insideTypes).add(source).add(target),
);
})
);
}
const isArrayLikeType = source instanceof ArrayType || source instanceof TupleType;
if (isArrayLikeType) {
const lengthPropType = targetMembers
.find((prop) => prop.getName() === "length" && prop.isRequired())
?.getType();
if (source instanceof ArrayType) {
return lengthPropType instanceof NumberType;
}
if (source instanceof TupleType) {
if (lengthPropType instanceof LiteralType) {
const types = source.getTypes();
const lengthPropValue = lengthPropType.getValue();
return types.length === lengthPropValue;
}
}
}
}
// Check if tuple types are compatible
if (target instanceof TupleType) {
if (source instanceof TupleType) {
const sourceMembers = source.getTypes();
const targetMembers = target.getTypes();
// TODO: Currently, the final element of the target tuple may be a
// rest type. However, since TypeScript 4.0, a tuple may contain
// multiple rest types at arbitrary locations.
return targetMembers.every((targetMember, i) => {
const numTarget = targetMembers.length;
const numSource = sourceMembers.length;
if (i == numTarget - 1) {
if (numTarget <= numSource + 1) {
if (targetMember instanceof RestType) {
const remaining: Array<BaseType> = [];
for (let j = i; j < numSource; j++) {
remaining.push(sourceMembers[j]);
}
return isAssignableTo(
targetMember.getType(),
new TupleType(remaining),
inferMap,
insideTypes,
);
}
// The type cannot be assigned if more than one source
// member is remaining and the final target type is not
// a rest type.
else if (numTarget < numSource) {
return false;
}
}
}
const sourceMember = sourceMembers[i];
if (targetMember instanceof OptionalType) {
if (sourceMember) {
return (
isAssignableTo(targetMember, sourceMember, inferMap, insideTypes) ||
isAssignableTo(targetMember.getType(), sourceMember, inferMap, insideTypes)
);
} else {
return true;
}
} else {
if (sourceMember === undefined) {
return false;
}
return isAssignableTo(targetMember, sourceMember, inferMap, insideTypes);
}
});
}
}
return false;
}