diff --git a/packages/lib/package.json b/packages/lib/package.json index 706f5c5b..f00e1433 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -45,9 +45,11 @@ "mobx": "^6.0.0 || ^5.0.0 || ^4.0.0" }, "devDependencies": { + "@types/dedent": "^0.7.0", "@types/jest": "^26.0.0", "@typescript-eslint/eslint-plugin": "^4.5.0", "@typescript-eslint/parser": "^4.5.0", + "dedent": "^0.7.0", "eslint": "^7.12.1", "eslint-plugin-flowtype": "^5.2.0", "eslint-plugin-jest": "^24.1.0", diff --git a/packages/lib/src/fnModel/fnModel.ts b/packages/lib/src/fnModel/fnModel.ts index 89e0db04..a6b76c5c 100644 --- a/packages/lib/src/fnModel/fnModel.ts +++ b/packages/lib/src/fnModel/fnModel.ts @@ -2,6 +2,7 @@ import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig" import { toTreeNode } from "../tweaker/tweak" import { AnyStandardType, TypeToData } from "../typeChecking/schemas" import { typeCheck } from "../typeChecking/typeCheck" +import { throwTypeCheckErrors } from "../typeChecking/TypeCheckErrors" import { assertIsString } from "../utils" import { extendFnModelActions, FnModelActions, FnModelActionsDef } from "./actions" import { extendFnModelFlowActions, FnModelFlowActions, FnModelFlowActionsDef } from "./flowActions" @@ -130,9 +131,9 @@ function fnModelCreateWithoutType(data: Data): Data { function fnModelCreateWithType(actualType: AnyStandardType, data: Data): Data { if (isModelAutoTypeCheckingEnabled()) { - const errors = typeCheck(actualType, data) - if (errors) { - errors.throw(data) + const err = typeCheck(actualType, data) + if (err) { + throwTypeCheckErrors(err, data) } } return toTreeNode(data) diff --git a/packages/lib/src/globalConfig/globalConfig.ts b/packages/lib/src/globalConfig/globalConfig.ts index ad3cff04..8ec9d7b7 100644 --- a/packages/lib/src/globalConfig/globalConfig.ts +++ b/packages/lib/src/globalConfig/globalConfig.ts @@ -29,6 +29,11 @@ export interface GlobalConfig { */ modelAutoTypeChecking: ModelAutoTypeCheckingMode + /** + * Model auto type-validation. + */ + modelAutoTypeValidation: boolean + /** * ID generator function for model ids. */ @@ -59,6 +64,7 @@ function defaultModelIdGenerator(): string { // defaults let globalConfig: GlobalConfig = { modelAutoTypeChecking: ModelAutoTypeCheckingMode.DevModeOnly, + modelAutoTypeValidation: false, modelIdGenerator: defaultModelIdGenerator, allowUndefinedArrayElements: false, showDuplicateModelNameWarnings: true, diff --git a/packages/lib/src/model/BaseModel.ts b/packages/lib/src/model/BaseModel.ts index bef446ee..fbda5014 100644 --- a/packages/lib/src/model/BaseModel.ts +++ b/packages/lib/src/model/BaseModel.ts @@ -6,7 +6,7 @@ import { getSnapshot } from "../snapshot/getSnapshot" import { SnapshotInOfModel, SnapshotInOfObject, SnapshotOutOfModel } from "../snapshot/SnapshotOf" import { typesModel } from "../typeChecking/model" import { typeCheck } from "../typeChecking/typeCheck" -import { TypeCheckError } from "../typeChecking/TypeCheckError" +import { TypeCheckErrors } from "../typeChecking/TypeCheckErrors" import { assertIsObject } from "../utils" import { getModelIdPropertyName } from "./getModelMetadata" import { modelIdKey, modelTypeKey } from "./metadata" @@ -138,9 +138,9 @@ export abstract class BaseModel< * Performs a type check over the model instance. * For this to work a data type has to be declared in the model decorator. * - * @returns A `TypeCheckError` or `null` if there is no error. + * @returns A `TypeCheckErrors` or `null` if there is no error. */ - typeCheck(): TypeCheckError | null { + typeCheck(): TypeCheckErrors | null { const type = typesModel(this.constructor as any) return typeCheck(type, this as any) } diff --git a/packages/lib/src/model/getModelMetadata.ts b/packages/lib/src/model/getModelMetadata.ts index 4447748b..d9ebfb7c 100644 --- a/packages/lib/src/model/getModelMetadata.ts +++ b/packages/lib/src/model/getModelMetadata.ts @@ -13,6 +13,11 @@ export interface ModelMetadata { */ dataType?: AnyType + /** + * Associated validation type for runtime checking (if any). + */ + validationType?: AnyType + /** * Property used as model id (usually `$modelId` unless overridden). */ diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index 9f8dc351..83c5cda9 100644 --- a/packages/lib/src/model/modelDecorator.ts +++ b/packages/lib/src/model/modelDecorator.ts @@ -1,6 +1,7 @@ import { HookAction } from "../action/hookActions" import { wrapModelMethodInActionIfNeeded } from "../action/wrapInAction" import { getGlobalConfig } from "../globalConfig" +import { AnyStandardType, TypeToData } from "../typeChecking/schemas" import { addHiddenProp, failure, @@ -12,21 +13,41 @@ import { import { AnyModel, ModelClass, modelInitializedSymbol } from "./BaseModel" import { modelTypeKey } from "./metadata" import { modelInfoByClass, modelInfoByName } from "./modelInfo" -import { modelUnwrappedClassSymbol } from "./modelSymbols" +import { modelMetadataSymbol, modelUnwrappedClassSymbol } from "./modelSymbols" import { assertIsModelClass } from "./utils" +type AllKeys = T extends unknown ? keyof T : never + +type AllValues = T extends object + ? K extends keyof T + ? T[K] + : never + : never + +type Unionize = { + [K in AllKeys]: AllValues +} + /** * Decorator that marks this class (which MUST inherit from the `Model` abstract class) * as a model. * + * @typeparam ValidationType Validation type. * @param name Unique name for the model type. Note that this name must be unique for your whole * application, so it is usually a good idea to use some prefix unique to your application domain. + * @param validationType Runtime validation type. */ -export const model = (name: string) => >(clazz: MC): MC => { - return internalModel(name)(clazz) +export const model = ( + name: string, + validationType?: ValidationType +) => >>>(clazz: MC): MC => { + return internalModel(name, validationType)(clazz) } -const internalModel = (name: string) => >(clazz: MC): MC => { +const internalModel = ( + name: string, + validationType?: ValidationType +) => >>>(clazz: MC): MC => { assertIsModelClass(clazz, "a model class") if (modelInfoByName[name]) { @@ -91,6 +112,7 @@ const internalModel = (name: string) => >(clazz: clazz.toString = () => `class ${clazz.name}#${name}` ;(clazz as any)[modelTypeKey] = name + ;(clazz as any)[modelMetadataSymbol].validationType = validationType // this also gives access to modelInitializersSymbol, modelPropertiesSymbol, modelDataTypeCheckerSymbol Object.setPrototypeOf(newClazz, clazz) diff --git a/packages/lib/src/model/newModel.ts b/packages/lib/src/model/newModel.ts index 81b28472..1b25d338 100644 --- a/packages/lib/src/model/newModel.ts +++ b/packages/lib/src/model/newModel.ts @@ -1,8 +1,11 @@ import { action, set } from "mobx" import { O } from "ts-toolbelt" -import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig" +import { getGlobalConfig, isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig" +import { getParent } from "../parent/path" import { tweakModel } from "../tweaker/tweakModel" import { tweakPlainObject } from "../tweaker/tweakPlainObject" +import { throwTypeCheckErrors } from "../typeChecking/TypeCheckErrors" +import { validationContext } from "../typeChecking/validation" import { failure, inDevMode, makePropReadonly } from "../utils" import { AnyModel, ModelPropsCreationData } from "./BaseModel" import { getModelIdPropertyName, getModelMetadata } from "./getModelMetadata" @@ -114,7 +117,7 @@ export const internalNewModel = action( if (isModelAutoTypeCheckingEnabled() && getModelMetadata(modelClass).dataType) { const err = modelObj.typeCheck() if (err) { - err.throw(modelObj) + throwTypeCheckErrors(err, modelObj) } } @@ -128,6 +131,15 @@ export const internalNewModel = action( } } + // validate model and provide the result via a context if needed + if (getGlobalConfig().modelAutoTypeValidation) { + validationContext.setComputed(modelObj, () => { + const parent = getParent(modelObj) + const result = parent ? validationContext.get(parent)! : modelObj.typeCheck() + return result + }) + } + return modelObj as M } ) diff --git a/packages/lib/src/parent/path.ts b/packages/lib/src/parent/path.ts index 34edd585..4a29f11c 100644 --- a/packages/lib/src/parent/path.ts +++ b/packages/lib/src/parent/path.ts @@ -264,11 +264,14 @@ const unresolved = { resolved: false } as const * @typeparam T Returned value type. * @param pathRootObject Object that serves as path root. * @param path Path as an string or number array. + * @param includeModelDataObjects Pass `true` to include model interim data objects (`$`) explicitly + * in `path` or `false` to automatically traverse to `$` for all model nodes (defaults to `false`). * @returns An object with `{ resolved: true, value: T }` or `{ resolved: false }`. */ export function resolvePath( pathRootObject: object, - path: Path + path: Path, + includeModelDataObjects: boolean = false ): | { resolved: true @@ -281,7 +284,7 @@ export function resolvePath( // unit tests rely on this to work with any object // assertTweakedObject(pathRootObject, "pathRootObject") - let current: any = modelToDataNode(pathRootObject) + let current: any = includeModelDataObjects ? pathRootObject : modelToDataNode(pathRootObject) let len = path.length for (let i = 0; i < len; i++) { @@ -296,10 +299,10 @@ export function resolvePath( return unresolved } - current = modelToDataNode(current[p]) + current = includeModelDataObjects ? current[p] : modelToDataNode(current[p]) } - return { resolved: true, value: dataToModelNode(current) } + return { resolved: true, value: includeModelDataObjects ? current : dataToModelNode(current) } } /** diff --git a/packages/lib/src/tweaker/typeChecking.ts b/packages/lib/src/tweaker/typeChecking.ts index 72b762a1..2e06b5e4 100644 --- a/packages/lib/src/tweaker/typeChecking.ts +++ b/packages/lib/src/tweaker/typeChecking.ts @@ -7,6 +7,7 @@ import { findParent } from "../parent/findParent" import { internalApplyPatches } from "../patch/applyPatches" import { InternalPatchRecorder } from "../patch/emitPatch" import { invalidateCachedTypeCheckerResult } from "../typeChecking/TypeChecker" +import { throwTypeCheckErrors } from "../typeChecking/TypeCheckErrors" import { runWithoutSnapshotOrPatches } from "./core" /** @@ -27,7 +28,7 @@ export function runTypeCheckingAfterChange(obj: object, patchRecorder: InternalP internalApplyPatches.call(obj, patchRecorder.invPatches, true) }) // at the end of apply patches it will be type checked again and its result cached once more - err.throw(parentModelWithTypeChecker) + throwTypeCheckErrors(err, parentModelWithTypeChecker) } } } diff --git a/packages/lib/src/typeChecking/TypeCheckError.ts b/packages/lib/src/typeChecking/TypeCheckError.ts deleted file mode 100644 index 269a73cd..00000000 --- a/packages/lib/src/typeChecking/TypeCheckError.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getRootPath } from "../parent/path" -import { Path } from "../parent/pathTypes" -import { isTweakedObject } from "../tweaker/core" -import { failure } from "../utils" - -/** - * A type checking error. - */ -export class TypeCheckError { - /** - * Creates an instance of TypeError. - * @param path Sub-path where the error occured. - * @param expectedTypeName Name of the expected type. - * @param actualValue Actual value. - */ - constructor(readonly path: Path, readonly expectedTypeName: string, readonly actualValue: any) {} - - /** - * Throws the type check error as an actual error. - * - * @param typeCheckedValue Usually the value where the type check was invoked. - */ - throw(typeCheckedValue: any): never { - let msg = "TypeCheckError: " - let rootPath: Path = [] - if (isTweakedObject(typeCheckedValue, true)) { - rootPath = getRootPath(typeCheckedValue).path - } - - msg += "[/" + [...rootPath, ...this.path].join("/") + "] " - - msg += "Expected: " + this.expectedTypeName - - throw failure(msg) - } -} diff --git a/packages/lib/src/typeChecking/TypeCheckErrors.ts b/packages/lib/src/typeChecking/TypeCheckErrors.ts new file mode 100644 index 00000000..d43db9ba --- /dev/null +++ b/packages/lib/src/typeChecking/TypeCheckErrors.ts @@ -0,0 +1,187 @@ +import { getRootPath } from "../parent/path" +import { Path } from "../parent/pathTypes" +import { isTweakedObject } from "../tweaker/core" +import { failure, isNonEmptyArray } from "../utils" +import { drawTree, Tree } from "../utils/drawTree" +import { ReadonlyNonEmptyArray } from "../utils/types" + +/** + * Type check errors. + */ +export type TypeCheckErrors = TypeCheckError | TypeCheckErrorExpression + +/** + * A type check error. + */ +export interface TypeCheckError { + /** + * Sub-path where the error occurred. + */ + readonly path: Path + + /** + * Name of the expected type. + */ + readonly expectedTypeName: string + + /** + * Actual value. + */ + readonly actualValue: unknown +} + +/** + * A type check error expression. + */ +export interface TypeCheckErrorExpression { + /** + * Expression operator. + */ + readonly op: "and" | "or" + + /** + * Expression operator arguments. + */ + readonly args: ReadonlyArray +} + +/** + * Creates a new type check error. + * + * @param path Sub-path where the error occurred. + * @param expectedTypeName Name of the expected type. + * @param actualValue Actual value. + * @returns The type check error. + */ +export function createTypeCheckError( + path: Path, + expectedTypeName: string, + actualValue: any +): TypeCheckError { + return { path, expectedTypeName, actualValue } +} + +/** + * Checks whether `errors` is an error expression. + * + * @param errors Type check errors. + * @returns `true` if `errors` is an error expression, otherwise `false`. + */ +export function isTypeCheckErrorExpression( + errors: TypeCheckErrors +): errors is TypeCheckErrorExpression { + return hasOwnProperty(errors, "op") +} + +/** + * Merges type check errors by creating a logical expression. + * + * @param op Type check expression operator used for merging errors. + * @param errors Type check errors to merge. + * @returns The merged type check errors. + */ +export function mergeTypeCheckErrors( + op: TypeCheckErrorExpression["op"], + errors: ReadonlyNonEmptyArray +): TypeCheckErrors { + if (errors.length === 1) { + return errors[0] + } + + const args: TypeCheckErrors[] = [] + for (const error of errors) { + if (isTypeCheckErrorExpression(error) && error.op === op) { + args.push(...error.args) + } else { + args.push(error) + } + } + + return { op, args } +} + +/** + * Type check error transformer function. + */ +export type TypeCheckErrorTransformer = (error: TypeCheckError) => TypeCheckError | null + +/** + * Transforms type check errors. If `transform` returns `null`, the type check error is omitted. + * + * @param errors Type check errors. + * @param transform Function that transforms a type check error. + * @returns The new type check errors. + */ +export function transformTypeCheckErrors( + errors: TypeCheckErrors, + transform: TypeCheckErrorTransformer +): TypeCheckErrors | null { + if (isTypeCheckErrorExpression(errors)) { + const newArgs: TypeCheckErrors[] = [] + for (const arg of errors.args) { + const newArg = transformTypeCheckErrors(arg, transform) + if (newArg) { + newArgs.push(newArg) + } + } + return isNonEmptyArray(newArgs) ? mergeTypeCheckErrors(errors.op, newArgs) : null + } + + return transform(errors) +} + +/** + * Throws the type check errors as an actual error. + * + * @param errors The type check errors. + * @param value Usually the value where the type check was invoked. + */ +export function throwTypeCheckErrors(errors: TypeCheckErrors, value: unknown): never { + const msg = getTypeCheckErrorMessage(errors, value) + throw failure("TypeCheckError:" + (msg.includes("\n") ? "\n" : " ") + msg) +} + +/** + * Gets the error message of type check errors. + * + * @param errors The type check errors. + * @param value Usually the value where the type check was invoked. + * @returns The error message. + */ +export function getTypeCheckErrorMessage(errors: TypeCheckErrors, value: unknown): string { + return drawTree(toTree(errors, value)) +} + +/** + * @internal + * + * Converts type check errors to an error tree representation. + * + * @param errors The type check errors. + * @param value Usually the value where the type check was invoked. + * @returns The error tree. + */ +function toTree(errors: TypeCheckErrors, value: unknown): Tree { + if (isTypeCheckErrorExpression(errors)) { + return { + value: errors.op.toUpperCase(), + forest: errors.args.map((arg) => toTree(arg, value)), + } + } + + let msg = "" + let rootPath: Path = [] + if (isTweakedObject(value, true)) { + rootPath = getRootPath(value).path + } + msg += "[/" + [...rootPath, ...errors.path].join("/") + "] " + msg += "Expected: " + errors.expectedTypeName + return { value: msg, forest: [] } +} + +/** + * @ignore + */ +function hasOwnProperty(value: unknown, key: K): value is Record { + return Object.prototype.hasOwnProperty.call(value, key) +} diff --git a/packages/lib/src/typeChecking/TypeChecker.ts b/packages/lib/src/typeChecking/TypeChecker.ts index d0460a3a..3d5a618d 100644 --- a/packages/lib/src/typeChecking/TypeChecker.ts +++ b/packages/lib/src/typeChecking/TypeChecker.ts @@ -4,13 +4,13 @@ import { isTweakedObject } from "../tweaker/core" import { failure, lateVal } from "../utils" import { resolveStandardType } from "./resolveTypeChecker" import { AnyStandardType, AnyType } from "./schemas" -import { TypeCheckError } from "./TypeCheckError" +import { transformTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" -type CheckFunction = (value: any, path: Path) => TypeCheckError | null +type CheckFunction = (value: any, path: Path) => TypeCheckErrors | null const emptyPath: Path = [] -type CheckResult = TypeCheckError | null +type CheckResult = TypeCheckErrors | null type CheckResultCache = WeakMap const typeCheckersWithCachedResultsOfObject = new WeakMap>() @@ -72,7 +72,7 @@ export class TypeChecker { return this.checkResultCache ? this.checkResultCache.get(obj) : undefined } - check(value: any, path: Path): TypeCheckError | null { + check(value: any, path: Path): TypeCheckErrors | null { if (this.unchecked) { return null } @@ -92,11 +92,10 @@ export class TypeChecker { } if (cachedResult) { - return new TypeCheckError( - [...path, ...cachedResult.path], - cachedResult.expectedTypeName, - cachedResult.actualValue - ) + return transformTypeCheckErrors(cachedResult, (cachedError) => ({ + ...cachedError, + path: [...path, ...cachedError.path], + })) } else { return null } diff --git a/packages/lib/src/typeChecking/array.ts b/packages/lib/src/typeChecking/array.ts index 58345e8e..46e4d4c9 100644 --- a/packages/lib/src/typeChecking/array.ts +++ b/packages/lib/src/typeChecking/array.ts @@ -1,8 +1,8 @@ -import { isArray } from "../utils" +import { isArray, isNonEmptyArray } from "../utils" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, ArrayType } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError, mergeTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" /** * A type that represents an array of values of a given type. @@ -28,16 +28,18 @@ export function typesArray(itemType: T): ArrayType { const thisTc: TypeChecker = new TypeChecker( (array, path) => { if (!isArray(array)) { - return new TypeCheckError(path, getTypeName(thisTc), array) + return createTypeCheckError(path, getTypeName(thisTc), array) } if (!itemChecker.unchecked) { + const itemErrors: TypeCheckErrors[] = [] for (let i = 0; i < array.length; i++) { const itemError = itemChecker.check(array[i], [...path, i]) if (itemError) { - return itemError + itemErrors.push(itemError) } } + return isNonEmptyArray(itemErrors) ? mergeTypeCheckErrors("and", itemErrors) : null } return null diff --git a/packages/lib/src/typeChecking/arraySet.ts b/packages/lib/src/typeChecking/arraySet.ts index 8cdae579..224d4637 100644 --- a/packages/lib/src/typeChecking/arraySet.ts +++ b/packages/lib/src/typeChecking/arraySet.ts @@ -4,7 +4,7 @@ import { typesObject } from "./object" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, IdentityType, TypeToData } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError } from "./TypeCheckErrors" /** * A type that represents an array backed set ArraySet. @@ -32,7 +32,7 @@ export function typesArraySet( const thisTc: TypeChecker = new TypeChecker( (obj, path) => { if (!(obj instanceof ArraySet)) { - return new TypeCheckError(path, getTypeName(thisTc), obj) + return createTypeCheckError(path, getTypeName(thisTc), obj) } const dataTypeChecker = typesObject(() => ({ diff --git a/packages/lib/src/typeChecking/index.ts b/packages/lib/src/typeChecking/index.ts index 5e47adc7..6aa88e71 100644 --- a/packages/lib/src/typeChecking/index.ts +++ b/packages/lib/src/typeChecking/index.ts @@ -1,5 +1,6 @@ export * from "./schemas" export * from "./tProp" export * from "./typeCheck" -export * from "./TypeCheckError" +export * from "./TypeCheckErrors" export * from "./types" +export * from "./validation" diff --git a/packages/lib/src/typeChecking/model.ts b/packages/lib/src/typeChecking/model.ts index 90c70c25..64da8e17 100644 --- a/packages/lib/src/typeChecking/model.ts +++ b/packages/lib/src/typeChecking/model.ts @@ -9,7 +9,7 @@ import { failure, lateVal } from "../utils" import { resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, IdentityType } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError, mergeTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" const cachedModelTypeChecker = new WeakMap, TypeChecker>() @@ -52,11 +52,13 @@ export function typesModel(modelClass: object): IdentityType { return new TypeChecker( (value, path) => { if (!(value instanceof modelClazz)) { - return new TypeCheckError(path, typeName, value) + return createTypeCheckError(path, typeName, value) } - const dataTypeChecker = getModelMetadata(value).dataType - if (!dataTypeChecker) { + const modelMetadata = getModelMetadata(value) + const dataTypeChecker = modelMetadata.dataType + const validationTypeChecker = modelMetadata.validationType + if (!dataTypeChecker && !validationTypeChecker) { throw failure( `type checking cannot be performed over model of type '${ modelInfo.name @@ -66,9 +68,28 @@ export function typesModel(modelClass: object): IdentityType { ) } - const resolvedTc = resolveTypeChecker(dataTypeChecker) - if (!resolvedTc.unchecked) { - return resolvedTc.check(value.$, path) + let dataErrors: TypeCheckErrors | null | undefined + if (dataTypeChecker) { + const resolvedTc = resolveTypeChecker(dataTypeChecker) + if (!resolvedTc.unchecked) { + dataErrors = resolvedTc.check(value.$, [...path, "$"]) + } + } + + let validationErrors: TypeCheckErrors | null | undefined + if (validationTypeChecker) { + const resolvedTc = resolveTypeChecker(validationTypeChecker) + if (!resolvedTc.unchecked) { + validationErrors = resolvedTc.check(value, path) + } + } + + if (dataErrors && validationErrors) { + return mergeTypeCheckErrors("and", [dataErrors, validationErrors]) + } else if (dataErrors) { + return dataErrors + } else if (validationErrors) { + return validationErrors } return null diff --git a/packages/lib/src/typeChecking/object.ts b/packages/lib/src/typeChecking/object.ts index 14564900..e4fee43e 100644 --- a/packages/lib/src/typeChecking/object.ts +++ b/packages/lib/src/typeChecking/object.ts @@ -1,6 +1,6 @@ import { O } from "ts-toolbelt" import { Frozen } from "../frozen/Frozen" -import { assertIsFunction, assertIsObject, isObject, lateVal } from "../utils" +import { assertIsFunction, assertIsObject, isNonEmptyArray, isObject, lateVal } from "../utils" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, ObjectType, ObjectTypeFunction } from "./schemas" import { @@ -11,7 +11,7 @@ import { TypeInfo, TypeInfoGen, } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError, mergeTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" function typesObjectHelper(objFn: S, frozen: boolean, typeInfoGen: TypeInfoGen): S { assertIsFunction(objFn, "objFn") @@ -41,20 +41,21 @@ function typesObjectHelper(objFn: S, frozen: boolean, typeInfoGen: TypeInfoGe const thisTc: TypeChecker = new TypeChecker( (obj, path) => { if (!isObject(obj) || (frozen && !(obj instanceof Frozen))) - return new TypeCheckError(path, getTypeName(thisTc), obj) + return createTypeCheckError(path, getTypeName(thisTc), obj) // note: we allow excess properties when checking objects + const valueErrors: TypeCheckErrors[] = [] for (const [k, unresolvedTc] of schemaEntries) { const tc = resolveTypeChecker(unresolvedTc) const objVal = obj[k] const valueError = !tc.unchecked ? tc.check(objVal, [...path, k]) : null if (valueError) { - return valueError + valueErrors.push(valueError) } } - return null + return isNonEmptyArray(valueErrors) ? mergeTypeCheckErrors("and", valueErrors) : null }, getTypeName, typeInfoGen diff --git a/packages/lib/src/typeChecking/objectMap.ts b/packages/lib/src/typeChecking/objectMap.ts index ec348d52..19e80d3c 100644 --- a/packages/lib/src/typeChecking/objectMap.ts +++ b/packages/lib/src/typeChecking/objectMap.ts @@ -4,7 +4,7 @@ import { typesRecord } from "./record" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, IdentityType, TypeToData } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError } from "./TypeCheckErrors" /** * A type that represents an object-like map ObjectMap. @@ -32,7 +32,7 @@ export function typesObjectMap( const thisTc: TypeChecker = new TypeChecker( (obj, path) => { if (!(obj instanceof ObjectMap)) { - return new TypeCheckError(path, getTypeName(thisTc), obj) + return createTypeCheckError(path, getTypeName(thisTc), obj) } const dataTypeChecker = typesObject(() => ({ diff --git a/packages/lib/src/typeChecking/or.ts b/packages/lib/src/typeChecking/or.ts index a8c19087..49ae1366 100644 --- a/packages/lib/src/typeChecking/or.ts +++ b/packages/lib/src/typeChecking/or.ts @@ -1,8 +1,8 @@ -import { lateVal } from "../utils" +import { isNonEmptyArray, lateVal } from "../utils" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { mergeTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" import { typesUnchecked } from "./unchecked" /** @@ -41,12 +41,16 @@ export function typesOr(...orTypes: T): T[number] { const thisTc: TypeChecker = new TypeChecker( (value, path) => { - const noMatchingType = checkers.every((tc) => !!tc.check(value, path)) - if (noMatchingType) { - return new TypeCheckError(path, getTypeName(thisTc), value) - } else { - return null + const errors: TypeCheckErrors[] = [] + for (const tc of checkers) { + const tcErrors = tc.check(value, path) + if (tcErrors) { + errors.push(tcErrors) + } else { + return null + } } + return isNonEmptyArray(errors) ? mergeTypeCheckErrors("or", errors) : null }, getTypeName, typeInfoGen diff --git a/packages/lib/src/typeChecking/primitives.ts b/packages/lib/src/typeChecking/primitives.ts index 509596b7..21acf456 100644 --- a/packages/lib/src/typeChecking/primitives.ts +++ b/packages/lib/src/typeChecking/primitives.ts @@ -3,7 +3,7 @@ import { PrimitiveValue } from "../utils/types" import { typesRefinement } from "./refinement" import { AnyStandardType, IdentityType } from "./schemas" import { TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError } from "./TypeCheckErrors" /** * A type that represents a certain value of a primitive (for example an *exact* number or string). @@ -36,7 +36,7 @@ export function typesLiteral(literal: T): IdentityType const typeInfoGen: TypeInfoGen = (t) => new LiteralTypeInfo(t, literal) return new TypeChecker( - (value, path) => (value === literal ? null : new TypeCheckError(path, typeName, value)), + (value, path) => (value === literal ? null : createTypeCheckError(path, typeName, value)), () => typeName, typeInfoGen ) as any @@ -79,7 +79,8 @@ export const typesNull = typesLiteral(null) * ``` */ export const typesBoolean: IdentityType = new TypeChecker( - (value, path) => (typeof value === "boolean" ? null : new TypeCheckError(path, "boolean", value)), + (value, path) => + typeof value === "boolean" ? null : createTypeCheckError(path, "boolean", value), () => "boolean", (t) => new BooleanTypeInfo(t) ) as any @@ -97,7 +98,7 @@ export class BooleanTypeInfo extends TypeInfo {} * ``` */ export const typesNumber: IdentityType = new TypeChecker( - (value, path) => (typeof value === "number" ? null : new TypeCheckError(path, "number", value)), + (value, path) => (typeof value === "number" ? null : createTypeCheckError(path, "number", value)), () => "number", (t) => new NumberTypeInfo(t) ) as any @@ -115,7 +116,7 @@ export class NumberTypeInfo extends TypeInfo {} * ``` */ export const typesString: IdentityType = new TypeChecker( - (value, path) => (typeof value === "string" ? null : new TypeCheckError(path, "string", value)), + (value, path) => (typeof value === "string" ? null : createTypeCheckError(path, "string", value)), () => "string", (t) => new StringTypeInfo(t) ) as any diff --git a/packages/lib/src/typeChecking/record.ts b/packages/lib/src/typeChecking/record.ts index 3bea4801..95a5403f 100644 --- a/packages/lib/src/typeChecking/record.ts +++ b/packages/lib/src/typeChecking/record.ts @@ -1,8 +1,8 @@ -import { isObject } from "../utils" +import { isNonEmptyArray, isObject } from "../utils" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, RecordType } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError, mergeTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" /** * A type that represents an object-like map, an object with string keys and values all of a same given type. @@ -28,18 +28,20 @@ export function typesRecord(valueType: T): RecordType { const thisTc: TypeChecker = new TypeChecker( (obj, path) => { - if (!isObject(obj)) return new TypeCheckError(path, getTypeName(thisTc), obj) + if (!isObject(obj)) return createTypeCheckError(path, getTypeName(thisTc), obj) if (!valueChecker.unchecked) { + const valueErrors: TypeCheckErrors[] = [] const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { const k = keys[i] const v = obj[k] const valueError = valueChecker.check(v, [...path, k]) if (valueError) { - return valueError + valueErrors.push(valueError) } } + return isNonEmptyArray(valueErrors) ? mergeTypeCheckErrors("and", valueErrors) : null } return null diff --git a/packages/lib/src/typeChecking/ref.ts b/packages/lib/src/typeChecking/ref.ts index 9dd77984..59506e9c 100644 --- a/packages/lib/src/typeChecking/ref.ts +++ b/packages/lib/src/typeChecking/ref.ts @@ -4,7 +4,7 @@ import { typesString } from "./primitives" import { resolveTypeChecker } from "./resolveTypeChecker" import { IdentityType } from "./schemas" import { TypeChecker, TypeInfo } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError } from "./TypeCheckErrors" /** * A type that represents a reference to an object or model. @@ -30,7 +30,7 @@ const refDataTypeChecker = typesObject(() => ({ const refTypeChecker = new TypeChecker( (value, path) => { if (!(value instanceof Ref)) { - return new TypeCheckError(path, typeName, value) + return createTypeCheckError(path, typeName, value) } const resolvedTc = resolveTypeChecker(refDataTypeChecker) diff --git a/packages/lib/src/typeChecking/refinement.ts b/packages/lib/src/typeChecking/refinement.ts index 14ef50e8..8245aeda 100644 --- a/packages/lib/src/typeChecking/refinement.ts +++ b/packages/lib/src/typeChecking/refinement.ts @@ -1,7 +1,7 @@ import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, TypeToData } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError, TypeCheckErrors } from "./TypeCheckErrors" /** * A refinement over a given type. This allows you to do extra checks @@ -23,20 +23,20 @@ import { TypeCheckError } from "./TypeCheckError" * return rightResult * * // this will return that the result field is wrong - * return rightResult ? null : new TypeCheckError(["result"], "a+b", sum.result) + * return rightResult ? null : createTypeCheckError(["result"], "a+b", sum.result) * }) * ``` * * @template T Base type. * @param baseType Base type. * @param checkFn Function that will receive the data (if it passes the base type - * check) and return null or false if there were no errors or either a TypeCheckError instance or + * check) and return null or false if there were no errors or either a TypeCheckErrors object or * true if there were. * @returns */ export function typesRefinement( baseType: T, - checkFn: (data: TypeToData) => TypeCheckError | null | boolean, + checkFn: (data: TypeToData) => TypeCheckErrors | null | boolean, typeName?: string ): T { const typeInfoGen: TypeInfoGen = (t) => @@ -63,7 +63,7 @@ export function typesRefinement( if (refinementErr === true) { return null } else if (refinementErr === false) { - return new TypeCheckError(path, getTypeName(thisTc), data) + return createTypeCheckError(path, getTypeName(thisTc), data) } else { return refinementErr ?? null } @@ -87,7 +87,7 @@ export class RefinementTypeInfo extends TypeInfo { constructor( thisType: AnyStandardType, readonly baseType: AnyStandardType, - readonly checkFunction: (data: any) => TypeCheckError | null | boolean, + readonly checkFunction: (data: any) => TypeCheckErrors | null | boolean, readonly typeName: string | undefined ) { super(thisType) diff --git a/packages/lib/src/typeChecking/tuple.ts b/packages/lib/src/typeChecking/tuple.ts index 1aa23da2..5bfabf00 100644 --- a/packages/lib/src/typeChecking/tuple.ts +++ b/packages/lib/src/typeChecking/tuple.ts @@ -1,8 +1,8 @@ -import { isArray, lateVal } from "../utils" +import { isArray, isNonEmptyArray, lateVal } from "../utils" import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" import { AnyStandardType, AnyType, ArrayType } from "./schemas" import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" -import { TypeCheckError } from "./TypeCheckError" +import { createTypeCheckError, mergeTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" /** * A type that represents an tuple of values of a given type. @@ -36,20 +36,21 @@ export function typesTuple(...itemTypes: T): ArrayType { const thisTc: TypeChecker = new TypeChecker( (array, path) => { if (!isArray(array) || array.length !== itemTypes.length) { - return new TypeCheckError(path, getTypeName(thisTc), array) + return createTypeCheckError(path, getTypeName(thisTc), array) } + const itemErrors: TypeCheckErrors[] = [] for (let i = 0; i < array.length; i++) { const itemChecker = checkers[i] if (!itemChecker.unchecked) { const itemError = itemChecker.check(array[i], [...path, i]) if (itemError) { - return itemError + itemErrors.push(itemError) } } } - return null + return isNonEmptyArray(itemErrors) ? mergeTypeCheckErrors("and", itemErrors) : null }, getTypeName, typeInfoGen diff --git a/packages/lib/src/typeChecking/typeCheck.ts b/packages/lib/src/typeChecking/typeCheck.ts index c8ac57ce..244d7893 100644 --- a/packages/lib/src/typeChecking/typeCheck.ts +++ b/packages/lib/src/typeChecking/typeCheck.ts @@ -1,6 +1,6 @@ import { resolveTypeChecker } from "./resolveTypeChecker" import { AnyType, TypeToData } from "./schemas" -import { TypeCheckError } from "./TypeCheckError" +import { TypeCheckErrors } from "./TypeCheckErrors" /** * Checks if a value conforms to a given type. @@ -8,9 +8,12 @@ import { TypeCheckError } from "./TypeCheckError" * @typename T Type. * @param type Type to check for. * @param value Value to check. - * @returns A TypeError if the check fails or null if no error. + * @returns A `TypeCheckErrors` if the check fails or `null` if no error. */ -export function typeCheck(type: T, value: TypeToData): TypeCheckError | null { +export function typeCheck( + type: T, + value: TypeToData +): TypeCheckErrors | null { const typeChecker = resolveTypeChecker(type) if (typeChecker.unchecked) { diff --git a/packages/lib/src/typeChecking/validation.ts b/packages/lib/src/typeChecking/validation.ts new file mode 100644 index 00000000..e140043f --- /dev/null +++ b/packages/lib/src/typeChecking/validation.ts @@ -0,0 +1,29 @@ +import fastDeepEqual from "fast-deep-equal/es6" +import { createContext } from "../context" +import { getRootPath } from "../parent/path" +import { transformTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" + +export const validationContext = createContext() + +/** + * Gets the validation result for the subtree of a node with the type check error path relative to + * the node. + * + * @param node Tree node. + * @returns `TypeCheckErrors` if there are errors, `null` if there is no error, and `undefined` if + * model type validation is not enabled in the global config. + */ +export function getValidationResult(node: object): TypeCheckErrors | null | undefined { + const errors = validationContext.get(node) + + if (!errors) { + return errors + } + + const nodePath = getRootPath(node).path + return transformTypeCheckErrors(errors, (error) => + fastDeepEqual(nodePath, error.path.slice(0, nodePath.length)) + ? { ...error, path: error.path.slice(nodePath.length) } + : null + ) +} diff --git a/packages/lib/src/utils/drawTree.ts b/packages/lib/src/utils/drawTree.ts new file mode 100644 index 00000000..c99178d0 --- /dev/null +++ b/packages/lib/src/utils/drawTree.ts @@ -0,0 +1,33 @@ +/** + * A tree. + */ +export interface Tree { + readonly value: V + readonly forest: ReadonlyArray> +} + +/** + * Draws a tree using unicode characters. + * + * @param tree The tree to draw. + * @returns A unicode drawing of the tree. + */ +export function drawTree(tree: Tree): string { + return tree.value + drawForest(tree.forest, "\n") +} + +/** + * @ignore + */ +function drawForest(forest: ReadonlyArray>, indentation: string): string { + let result: string = "" + const numTrees = forest.length + const last = numTrees - 1 + for (let i = 0; i < forest.length; i++) { + const tree = forest[i] + const isLast = i === last + result += indentation + (isLast ? "└" : "├") + "─ " + tree.value + result += drawForest(tree.forest, indentation + (numTrees > 1 && !isLast ? "│ " : " ")) + } + return result +} diff --git a/packages/lib/src/utils/index.ts b/packages/lib/src/utils/index.ts index 79573d56..e727746d 100644 --- a/packages/lib/src/utils/index.ts +++ b/packages/lib/src/utils/index.ts @@ -8,7 +8,7 @@ import { ObservableMap, ObservableSet, } from "mobx" -import { PrimitiveValue } from "./types" +import { NonEmptyArray, PrimitiveValue } from "./types" /** * A mobx-keystone error. @@ -153,6 +153,16 @@ export function isArray(val: any): val is any[] | IObservableArray { return Array.isArray(val) || isObservableArray(val) } +/** + * Checks whether an array is non-empty. + * + * @param arr Array to check. + * @returns `true` if `arr` is non-empty, otherwise `false`. + */ +export function isNonEmptyArray(arr: T[]): arr is NonEmptyArray { + return arr.length !== 0 +} + /** * @ignore * @internal diff --git a/packages/lib/src/utils/types.ts b/packages/lib/src/utils/types.ts index 1481e521..77d55e3e 100644 --- a/packages/lib/src/utils/types.ts +++ b/packages/lib/src/utils/types.ts @@ -29,3 +29,21 @@ export type IsOptionalValue = undefined extends C ? TV : FV // type _D = IsOptionalValue // false, but we don't care // type _E = IsOptionalValue // true // type _F = IsOptionalValue // true + +/** + * @ignore + * + * An array that must have at least one element. + */ +export interface NonEmptyArray extends Array { + 0: T +} + +/** + * @ignore + * + * A read-only array that must have at least one element. + */ +export interface ReadonlyNonEmptyArray extends ReadonlyArray { + readonly 0: T +} diff --git a/packages/lib/test/model/subclassing.test.ts b/packages/lib/test/model/subclassing.test.ts index bf2c9c7e..2865d9d0 100644 --- a/packages/lib/test/model/subclassing.test.ts +++ b/packages/lib/test/model/subclassing.test.ts @@ -361,11 +361,11 @@ test("three level subclassing", () => { // type checking must still work expect(() => { p2.setX("10" as any) - }).toThrow("TypeCheckError: [/x] Expected: number") + }).toThrow("TypeCheckError: [/$/x] Expected: number") expect(() => { p2.setB("10" as any) - }).toThrow("TypeCheckError: [/b] Expected: number") + }).toThrow("TypeCheckError: [/$/b] Expected: number") }) test("abstract-ish model classes with factory", () => { diff --git a/packages/lib/test/typeChecking/TypeCheckErrors.test.ts b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts new file mode 100644 index 00000000..0fe3ac78 --- /dev/null +++ b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts @@ -0,0 +1,140 @@ +import dedent from "dedent" +import { + createTypeCheckError, + getTypeCheckErrorMessage, + isTypeCheckErrorExpression, + mergeTypeCheckErrors, + transformTypeCheckErrors, + TypeCheckErrors, +} from "../../src" +import { ReadonlyNonEmptyArray } from "../../src/utils/types" + +test("createTypeCheckError", () => { + expect(createTypeCheckError(["x"], "number", "1")).toEqual({ + path: ["x"], + expectedTypeName: "number", + actualValue: "1", + }) +}) + +test("isTypeCheckErrorExpression", () => { + expect( + isTypeCheckErrorExpression( + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "1"), + createTypeCheckError(["y"], "number", "1"), + ]) + ) + ).toBeTruthy() + + expect(isTypeCheckErrorExpression(createTypeCheckError(["x"], "number", "1"))).toBeFalsy() +}) + +describe("mergeTypeCheckErrors", () => { + test("one argument", () => { + const error = createTypeCheckError([], "number", "abc") + expect(mergeTypeCheckErrors("and", [error])).toEqual(error) + }) + + test("two arguments (not same operator)", () => { + const errors: ReadonlyNonEmptyArray = [ + createTypeCheckError(["x"], "number", "x"), + mergeTypeCheckErrors("or", [ + createTypeCheckError(["y"], "number", "y"), + createTypeCheckError(["y"], "undefined", "y"), + ]), + ] + expect(mergeTypeCheckErrors("and", errors)).toEqual({ op: "and", args: errors }) + }) + + test("two arguments (same operator)", () => { + expect( + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "x"), + mergeTypeCheckErrors("and", [ + createTypeCheckError(["y", "a"], "number", "a"), + createTypeCheckError(["y", "b"], "number", "b"), + ]), + ]) + ).toEqual( + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "x"), + createTypeCheckError(["y", "a"], "number", "a"), + createTypeCheckError(["y", "b"], "number", "b"), + ]) + ) + }) +}) + +test("transformTypeCheckErrors", () => { + expect( + transformTypeCheckErrors( + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "1"), + createTypeCheckError(["y"], "number", "2"), + ]), + // Filter errors with path `["x"]`. + (error) => (error.path[0] === "x" ? error : null) + ) + ).toEqual(createTypeCheckError(["x"], "number", "1")) +}) + +describe("getTypeCheckErrorMessage", () => { + test("single error", () => { + expect(getTypeCheckErrorMessage(createTypeCheckError([], "number", "abc"), "abc")).toBe( + "[/] Expected: number" + ) + }) + + test("error expression - simple", () => { + expect( + getTypeCheckErrorMessage( + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "x"), + createTypeCheckError(["y"], "number", "y"), + ]), + { + x: "x", + y: "y", + } + ) + ).toBe( + dedent` + AND + ├─ [/x] Expected: number + └─ [/y] Expected: number + ` + ) + }) + + test("error expression - complex", () => { + expect( + getTypeCheckErrorMessage( + mergeTypeCheckErrors("or", [ + mergeTypeCheckErrors("and", [ + createTypeCheckError(["kind"], `"float"`, `"boolean"`), + createTypeCheckError(["value"], "number", true), + ]), + mergeTypeCheckErrors("and", [ + createTypeCheckError(["kind"], `"int"`, `"boolean"`), + createTypeCheckError(["value"], "integer", true), + ]), + ]), + { + kind: "boolean", + value: true, + } + ) + ).toBe( + dedent` + OR + ├─ AND + │ ├─ [/kind] Expected: "float" + │ └─ [/value] Expected: number + └─ AND + ├─ [/kind] Expected: "int" + └─ [/value] Expected: integer + ` + ) + }) +}) diff --git a/packages/lib/test/typeChecking/typeChecking.test.ts b/packages/lib/test/typeChecking/typeChecking.test.ts index 256aa12c..fb4bc7d6 100644 --- a/packages/lib/test/typeChecking/typeChecking.test.ts +++ b/packages/lib/test/typeChecking/typeChecking.test.ts @@ -1,4 +1,4 @@ -import { reaction } from "mobx" +import { computed, reaction } from "mobx" import { assert, _ } from "spec.ts" import { actionTrackingMiddleware, @@ -9,11 +9,14 @@ import { ArraySetTypeInfo, ArrayTypeInfo, BooleanTypeInfo, + createTypeCheckError, customRef, frozen, FrozenTypeInfo, getTypeInfo, + getValidationResult, LiteralTypeInfo, + mergeTypeCheckErrors, model, Model, modelAction, @@ -42,7 +45,7 @@ import { tProp, TupleTypeInfo, typeCheck, - TypeCheckError, + TypeCheckErrors, TypeInfo, types, TypeToData, @@ -105,10 +108,22 @@ function expectTypeCheckOk(t: T, val: TypeToData) { expect(err).toBeNull() } -function expectTypeCheckFail(t: T, val: any, path: Path, expected: string) { +function expectTypeCheckFail(t: T, val: any, errors: TypeCheckErrors): void +function expectTypeCheckFail(t: T, val: any, path: Path, expected: string): void +function expectTypeCheckFail( + t: T, + val: any, + ...errorInfo: [TypeCheckErrors] | [Path, string] +): void { const err = typeCheck(t, val) - const { value: actualValue } = resolvePath(val, path) - expect(err).toEqual(new TypeCheckError(path, expected, actualValue)) + if (errorInfo.length === 1) { + const [errors] = errorInfo + expect(err).toEqual(errors) + } else { + const [path, expected] = errorInfo + const { value: actualValue } = resolvePath(val, path, true) + expect(err).toEqual(createTypeCheckError(path, expected, actualValue)) + } } function expectValidTypeInfo( @@ -246,7 +261,14 @@ test("or - simple types", () => { expectTypeCheckOk(type, 6) expectTypeCheckOk(type, false) - expectTypeCheckFail(type, "ho", [], "number | boolean") + expectTypeCheckFail( + type, + "ho", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "number", "ho"), + createTypeCheckError([], "boolean", "ho"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypes).toEqual([types.number, types.boolean]) @@ -259,7 +281,14 @@ test("or - simple simple types", () => { expectTypeCheckOk(type, 6) expectTypeCheckOk(type, false) - expectTypeCheckFail(type, "ho", [], "number | boolean") + expectTypeCheckFail( + type, + "ho", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "number", "ho"), + createTypeCheckError([], "boolean", "ho"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypes).toEqual([types.number, types.boolean]) @@ -272,7 +301,14 @@ test("maybe", () => { expectTypeCheckOk(type, 6) expectTypeCheckOk(type, undefined) - expectTypeCheckFail(type, "ho", [], "number | undefined") + expectTypeCheckFail( + type, + "ho", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "number", "ho"), + createTypeCheckError([], "undefined", "ho"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypes).toEqual([types.number, types.undefined]) @@ -285,7 +321,14 @@ test("maybeNull", () => { expectTypeCheckOk(type, 6) expectTypeCheckOk(type, null) - expectTypeCheckFail(type, "ho", [], "number | null") + expectTypeCheckFail( + type, + "ho", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "number", "ho"), + createTypeCheckError([], "null", "ho"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypes).toEqual([types.number, types.null]) @@ -394,7 +437,14 @@ test("object - all optional simple types", () => { const expected = "{ x: number | undefined; y: string | undefined; }" expectTypeCheckFail(type, "ho", [], expected) - expectTypeCheckFail(type, { x: 5, y: 6 }, ["y"], "string | undefined") + expectTypeCheckFail( + type, + { x: 5, y: 6 }, + mergeTypeCheckErrors("or", [ + createTypeCheckError(["y"], "string", 6), + createTypeCheckError(["y"], "undefined", 6), + ]) + ) // excess properties are allowed expectTypeCheckOk(type, { x: 5, y: "6" }) @@ -449,8 +499,8 @@ test("model", () => { expectTypeCheckFail(type, "ho", [], `Model(${m.$modelType})`) expectTypeCheckFail(type, new MR({}), [], `Model(${m.$modelType})`) m.setX("10" as any) - expectTypeCheckFail(type, m, ["x"], "number") - expect(m.typeCheck()).toEqual(new TypeCheckError(["x"], "number", "10")) + expectTypeCheckFail(type, m, ["$", "x"], "number") + expect(m.typeCheck()).toEqual(createTypeCheckError(["$", "x"], "number", "10")) const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(M) @@ -535,7 +585,7 @@ test("new model with typechecking enabled", () => { modelAutoTypeChecking: ModelAutoTypeCheckingMode.AlwaysOn, }) - expect(() => new M({ x: 10, y: 20 as any })).toThrow("TypeCheckError: [/y] Expected: string") + expect(() => new M({ x: 10, y: 20 as any })).toThrow("TypeCheckError: [/$/y] Expected: string") }) test("array - complex types", () => { @@ -586,6 +636,30 @@ test("array - undefined", () => { expect(typeInfo.itemTypeInfo).toEqual(getTypeInfo(types.undefined)) }) +test("array - multiple errors", () => { + const type = types.array(types.number) + expectTypeCheckFail( + type, + ["1", true], + mergeTypeCheckErrors("and", [ + createTypeCheckError([0], "number", "1"), + createTypeCheckError([1], "number", true), + ]) + ) +}) + +test("tuple - multiple errors", () => { + const type = types.tuple(types.number, types.string) + expectTypeCheckFail( + type, + ["1", 1], + mergeTypeCheckErrors("and", [ + createTypeCheckError([0], "number", "1"), + createTypeCheckError([1], "string", 1), + ]) + ) +}) + test("object - complex types", () => { const xType = types.maybe(types.number) const oType = types.object(() => ({ @@ -629,6 +703,18 @@ test("object - complex types", () => { } as ObjectTypeInfoProps) }) +test("object - multiple errors", () => { + const type = types.object(() => ({ x: types.number, y: types.number })) + expectTypeCheckFail( + type, + { x: "1", y: true }, + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "1"), + createTypeCheckError(["y"], "number", true), + ]) + ) +}) + test("record - complex types", () => { const valueType = types.object(() => ({ y: types.string, @@ -649,6 +735,18 @@ test("record - complex types", () => { expect(typeInfo.valueTypeInfo).toEqual(getTypeInfo(valueType)) }) +test("record - multiple errors", () => { + const type = types.record(types.number) + expectTypeCheckFail( + type, + { x: "1", y: true }, + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "1"), + createTypeCheckError(["y"], "number", true), + ]) + ) +}) + test("or - complex types", () => { const typeA = types.object(() => ({ y: types.string, @@ -661,9 +759,22 @@ test("or - complex types", () => { expectTypeCheckOk(type, { y: "6" }) expectTypeCheckOk(type, 6) - const expected = "{ y: string; } | number" - expectTypeCheckFail(type, "ho", [], expected) - expectTypeCheckFail(type, { y: 6 }, [], expected) + expectTypeCheckFail( + type, + "ho", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "{ y: string; }", "ho"), + createTypeCheckError([], "number", "ho"), + ]) + ) + expectTypeCheckFail( + type, + { y: 6 }, + mergeTypeCheckErrors("or", [ + createTypeCheckError(["y"], "string", 6), + createTypeCheckError([], "number", { y: 6 }), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypes).toEqual([typeA, typeB]) @@ -703,12 +814,22 @@ test("recursive object", () => { expectTypeCheckOk(type, { x: 5 }) expectTypeCheckOk(type, { x: 5, rec: { x: 6 } }) + expectTypeCheckFail( + type, + { x: 5, rec: "ho" }, + mergeTypeCheckErrors("or", [ + createTypeCheckError(["rec"], "{ x: number; rec: ... | undefined; }", "ho"), + createTypeCheckError(["rec"], "undefined", "ho"), + ]) + ) expectTypeCheckFail( type, { x: 5, rec: { x: "6" } }, - ["rec"], - // won't say anything of the wrong x because of the or (maybe) type - "{ x: number; rec: ...; } | undefined" + mergeTypeCheckErrors("or", [ + createTypeCheckError(["rec", "x"], "number", "6"), + createTypeCheckError(["rec"], "undefined", { x: "6" }), + // won't say anything about the missing nested `rec` because of the `or` (`maybe`) type + ]) ) const typeInfo = expectValidTypeInfo(type, ObjectTypeInfo) @@ -741,12 +862,31 @@ test("cross referenced object", () => { expectTypeCheckOk(typeA, { x: 5, b: { y: 5, a: undefined } }) expectTypeCheckOk(typeA, { x: 5, b: { y: 5, a: { x: 6, b: undefined } } }) + expectTypeCheckFail( + typeA, + "ho", + [], + "{ x: number; b: { y: number; a: ... | undefined; } | undefined; }" + ) + expectTypeCheckFail( + typeA, + { x: 5, b: "ho" }, + mergeTypeCheckErrors("or", [ + createTypeCheckError( + ["b"], + "{ y: number; a: { x: number; b: ... | undefined; } | undefined; }", + "ho" + ), + createTypeCheckError(["b"], "undefined", "ho"), + ]) + ) expectTypeCheckFail( typeA, { x: 5, b: { y: "6", a: undefined } }, - ["b"], - // won't say anything of the wrong y because of the or (maybe) type - "{ y: number; a: { x: number; b: ...; } | undefined; } | undefined" + mergeTypeCheckErrors("or", [ + createTypeCheckError(["b", "y"], "number", "6"), + createTypeCheckError(["b"], "undefined", { y: "6", a: undefined }), + ]) ) { @@ -794,7 +934,14 @@ test("recursive model", () => { expectTypeCheckOk(type, mr) mr.setRec("5" as any) - expectTypeCheckFail(type, mr, ["rec"], "Model(MR) | undefined") + expectTypeCheckFail( + type, + mr, + mergeTypeCheckErrors("or", [ + createTypeCheckError(["$", "rec"], "Model(MR)", "5"), + createTypeCheckError(["$", "rec"], "undefined", "5"), + ]) + ) const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(MR) @@ -844,7 +991,15 @@ test("cross referenced model", () => { expectTypeCheckOk(type, ma) ma.b!.setA("5" as any) - expectTypeCheckFail(type, ma, ["b"], "Model(MB) | undefined") + expectTypeCheckFail( + type, + ma, + mergeTypeCheckErrors("or", [ + createTypeCheckError(["$", "b", "$", "a"], "Model(MA)", "5"), + createTypeCheckError(["$", "b", "$", "a"], "undefined", "5"), + createTypeCheckError(["$", "b"], "undefined", ma.b!), + ]) + ) const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(MA) @@ -927,7 +1082,14 @@ test("enum (string)", () => { assert(_ as TypeToData, _ as A) expectTypeCheckOk(type, A.X2) - expectTypeCheckFail(type, "X1", [], `"x1" | "x2"`) + expectTypeCheckFail( + type, + "X1", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], `"x1"`, "X1"), + createTypeCheckError([], `"x2"`, "X1"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypeInfos).toHaveLength(2) @@ -950,7 +1112,14 @@ test("enum (number)", () => { assert(_ as TypeToData, _ as A) expectTypeCheckOk(type, A.X2) - expectTypeCheckFail(type, "X1", [], `0 | 1`) + expectTypeCheckFail( + type, + "X1", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "0", "X1"), + createTypeCheckError([], "1", "X1"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypeInfos).toHaveLength(2) @@ -975,7 +1144,15 @@ test("enum (mixed)", () => { expectTypeCheckOk(type, A.X15) expectTypeCheckOk(type, A.X2) - expectTypeCheckFail(type, "X1", [], `0 | "x15" | 6`) + expectTypeCheckFail( + type, + "X1", + mergeTypeCheckErrors("or", [ + createTypeCheckError([], "0", "X1"), + createTypeCheckError([], `"x15"`, "X1"), + createTypeCheckError([], "6", "X1"), + ]) + ) const typeInfo = expectValidTypeInfo(type, OrTypeInfo) expect(typeInfo.orTypeInfos).toHaveLength(3) @@ -1049,7 +1226,7 @@ test("refinement (complex)", () => { const type = types.refinement(sumObjType, (sum) => { const rightResult = sum.a + sum.b === sum.result - return rightResult ? null : new TypeCheckError(["result"], "a+b", sum.result) + return rightResult ? null : createTypeCheckError(["result"], "a+b", sum.result) }) assert(_ as TypeToData, _ as { b: number; a: number; result: number }) @@ -1236,38 +1413,269 @@ test("syntax sugar for primitives in tProp", () => { expectTypeCheckOk(type, ss) ss.setN("10" as any) - expectTypeCheckFail(type, ss, ["n"], "number") + expectTypeCheckFail(type, ss, ["$", "n"], "number") ss.setN(42) ss.setS(10 as any) - expectTypeCheckFail(type, ss, ["s"], "string") + expectTypeCheckFail(type, ss, ["$", "s"], "string") ss.setS("foo") ss.setB("10" as any) - expectTypeCheckFail(type, ss, ["b"], "boolean") + expectTypeCheckFail(type, ss, ["$", "b"], "boolean") ss.setB(true) ss.setN2("10" as any) - expectTypeCheckFail(type, ss, ["n2"], "number") + expectTypeCheckFail(type, ss, ["$", "n2"], "number") ss.setN2(42) ss.setS2(10 as any) - expectTypeCheckFail(type, ss, ["s2"], "string") + expectTypeCheckFail(type, ss, ["$", "s2"], "string") ss.setS2("foo") ss.setB2("10" as any) - expectTypeCheckFail(type, ss, ["b2"], "boolean") + expectTypeCheckFail(type, ss, ["$", "b2"], "boolean") ss.setB2(true) ss.setNul(10 as any) - expectTypeCheckFail(type, ss, ["nul"], "null") + expectTypeCheckFail(type, ss, ["$", "nul"], "null") ss.setNul(null) ss.setUndef("10" as any) - expectTypeCheckFail(type, ss, ["undef"], "undefined") + expectTypeCheckFail(type, ss, ["$", "undef"], "undefined") ss.setUndef(undefined) ss.setOr({} as any) - expectTypeCheckFail(type, ss, ["or"], "string | number | boolean") + expectTypeCheckFail( + type, + ss, + mergeTypeCheckErrors("or", [ + createTypeCheckError(["$", "or"], "string", {}), + createTypeCheckError(["$", "or"], "number", {}), + createTypeCheckError(["$", "or"], "boolean", {}), + ]) + ) ss.setOr(5) }) + +describe("model type validation", () => { + beforeAll(() => { + setGlobalConfig({ modelAutoTypeValidation: true }) + }) + + afterAll(() => { + setGlobalConfig({ modelAutoTypeValidation: false }) + }) + + test("simple", () => { + @model("ValidatedModel/simple", types.object(() => ({ value: types.integer }))) + class M extends Model({ + value: prop(), + }) {} + + expect(new M({ value: 10 }).typeCheck()).toBeNull() + expect(new M({ value: 10.5 }).typeCheck()).toEqual( + createTypeCheckError(["value"], "integer", 10.5) + ) + }) + + test("complex - union", () => { + @model( + "ValidatedModel/complex-union", + types.or( + types.object(() => ({ + kind: types.literal("float"), + value: types.number, + })), + types.object(() => ({ + kind: types.literal("int"), + value: types.integer, + })) + ) + ) + class M extends Model({ + kind: prop<"float" | "int">(), + value: prop(), + }) {} + + expect(new M({ kind: "float", value: 10.5 }).typeCheck()).toBeNull() + expect(new M({ kind: "int", value: 10 }).typeCheck()).toBeNull() + const m = new M({ kind: "int", value: 10.5 }) + expect(m.typeCheck()).toEqual( + mergeTypeCheckErrors("or", [ + createTypeCheckError(["kind"], `"float"`, "int"), + createTypeCheckError(["value"], `integer`, 10.5), + ]) + ) + }) + + test("class property", () => { + @model("ValidatedModel/class-property", types.object(() => ({ value: types.number }))) + class M extends Model({}) { + value: number = 10 + } + + expect(new M({}).typeCheck()).toBeNull() + }) + + test("computed property", () => { + @model( + "ValidatedModel/computed-property", + types.object(() => ({ computedValue: types.integer })) + ) + class M extends Model({ + value: prop(), + }) { + @computed + get computedValue(): number { + return this.value + } + } + + expect(new M({ value: 10 }).typeCheck()).toBeNull() + expect(new M({ value: 10.5 }).typeCheck()).toEqual( + createTypeCheckError(["computedValue"], "integer", 10.5) + ) + }) + + test("child model", () => { + @model("ValidatedModel/child-model/Child", types.object(() => ({ value: types.integer }))) + class Child extends Model({ + value: prop(), + }) {} + + @model( + "ValidatedModel/child-model/Parent", + types.object(() => ({ child: types.model(Child) })) + ) + class Parent extends Model({ + child: prop(), + }) {} + + expect(new Parent({ child: new Child({ value: 10 }) }).typeCheck()).toBeNull() + + const parent = new Parent({ child: new Child({ value: 10.5 }) }) + expect(parent.typeCheck()).toEqual( + createTypeCheckError(["child", "value"], "integer", 10.5) + ) + }) + + test("reactive context", () => { + @model("ValidatedModel/reactive-context", types.object(() => ({ value: types.integer }))) + class M extends Model({ + value: prop(), + }) { + @modelAction + setValue(value: number): void { + this.value = value + } + } + + const m = new M({ value: 10 }) + + const errors: Array = [] + autoDispose( + reaction( + () => getValidationResult(m), + (error) => { + errors.push(error) + }, + { fireImmediately: true } + ) + ) + + m.setValue(10.5) + m.setValue(11.5) + m.setValue(11) + + expect(errors).toEqual([ + null, + createTypeCheckError(["value"], "integer", 10.5), + createTypeCheckError(["value"], "integer", 11.5), + null, + ]) + }) + + test("reactive context with child models", () => { + @model( + "ValidatedModel/reactive-context-with-child-models/Child", + types.object(() => ({ value: types.integer })) + ) + class Child extends Model({ + value: prop(), + }) { + @modelAction + setValue(value: number): void { + this.value = value + } + } + + @model( + "ValidatedModel/reactive-context-with-child-models/Parent", + types.object(() => ({ child1: types.model(Child), child2: types.model(Child) })) + ) + class Parent extends Model({ + child1: prop(), + child2: prop(), + }) {} + + const child1 = new Child({ value: 10 }) + const child2 = new Child({ value: 20 }) + const parent = new Parent({ child1, child2 }) + + const errors: Record< + "parent" | "child1" | "child2", + Array + > = { + parent: [], + child1: [], + child2: [], + } + autoDispose( + reaction( + () => getValidationResult(parent), + (error) => { + errors.parent.push(error) + }, + { fireImmediately: true } + ) + ) + autoDispose( + reaction( + () => getValidationResult(child1), + (error) => { + errors.child1.push(error) + }, + { fireImmediately: true } + ) + ) + autoDispose( + reaction( + () => getValidationResult(child2), + (error) => { + errors.child2.push(error) + }, + { fireImmediately: true } + ) + ) + + child1.setValue(10.5) + child1.setValue(11.5) + child1.setValue(11) + + expect(errors).toEqual({ + parent: [ + null, + createTypeCheckError(["child1", "value"], "integer", 10.5), + createTypeCheckError(["child1", "value"], "integer", 11.5), + null, + ], + child1: [ + null, + createTypeCheckError(["value"], "integer", 10.5), + createTypeCheckError(["value"], "integer", 11.5), + null, + ], + child2: [null], + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 8975c60e..bb3dfa90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3087,6 +3087,11 @@ dependencies: "@types/node" "*" +"@types/dedent@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050" + integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A== + "@types/download@^6.2.4": version "6.2.4" resolved "https://registry.yarnpkg.com/@types/download/-/download-6.2.4.tgz#d9fb74defe20d75f59a38a9b5b0eb5037d37161a"