From b8e24b186f34a8cccc97ad0182823efdcd405c39 Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sat, 12 Dec 2020 21:44:09 +0100 Subject: [PATCH 01/11] Add runtime type validation --- packages/lib/src/model/BaseModel.ts | 12 ++++ .../lib/src/model/getModelValidationType.ts | 24 +++++++ packages/lib/src/model/index.ts | 1 + packages/lib/src/model/modelDecorator.ts | 30 +++++++-- packages/lib/src/model/modelSymbols.ts | 1 + .../test/typeChecking/typeChecking.test.ts | 66 +++++++++++++++++++ 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/lib/src/model/getModelValidationType.ts diff --git a/packages/lib/src/model/BaseModel.ts b/packages/lib/src/model/BaseModel.ts index eb89ce54..cb3c87b4 100644 --- a/packages/lib/src/model/BaseModel.ts +++ b/packages/lib/src/model/BaseModel.ts @@ -8,6 +8,7 @@ import { typesModel } from "../typeChecking/model" import { typeCheck } from "../typeChecking/typeCheck" import { TypeCheckError } from "../typeChecking/TypeCheckError" import { assertIsObject } from "../utils" +import { getModelValidationType } from "./getModelValidationType" import { modelIdKey, modelTypeKey } from "./metadata" import { ModelConstructorOptions } from "./ModelConstructorOptions" import { modelInfoByClass } from "./modelInfo" @@ -126,6 +127,17 @@ export abstract class BaseModel< return typeCheck(type, this as any) } + /** + * Performs type validation over the model instance. + * For this to work a validation type has do be declared in the model decorator. + * + * @returns A `TypeCheckError` or `null` if there is no error. + */ + typeValidate(): TypeCheckError | null { + const type = getModelValidationType(this.constructor as any) + return typeCheck(type, this as any) + } + /** * Creates an instance of Model. */ diff --git a/packages/lib/src/model/getModelValidationType.ts b/packages/lib/src/model/getModelValidationType.ts new file mode 100644 index 00000000..3e07c9f2 --- /dev/null +++ b/packages/lib/src/model/getModelValidationType.ts @@ -0,0 +1,24 @@ +import { AnyType } from "../typeChecking/schemas" +import { failure } from "../utils" +import { AnyModel, ModelClass } from "./BaseModel" +import { modelValidationTypeCheckerSymbol } from "./modelSymbols" +import { isModel, isModelClass } from "./utils" + +/** + * Returns the associated validation type for runtime checking (if any) to a model instance or + * class. + * + * @param modelClassOrInstance Model class or instance. + * @returns The associated validation type, or `undefined` if none. + */ +export function getModelValidationType( + modelClassOrInstance: AnyModel | ModelClass +): AnyType | undefined { + if (isModel(modelClassOrInstance)) { + return (modelClassOrInstance as any).constructor[modelValidationTypeCheckerSymbol] + } else if (isModelClass(modelClassOrInstance)) { + return (modelClassOrInstance as any)[modelValidationTypeCheckerSymbol] + } else { + throw failure(`modelClassOrInstance must be a model class or instance`) + } +} diff --git a/packages/lib/src/model/index.ts b/packages/lib/src/model/index.ts index b6c34332..ab9a3714 100644 --- a/packages/lib/src/model/index.ts +++ b/packages/lib/src/model/index.ts @@ -1,5 +1,6 @@ export * from "./BaseModel" export * from "./getModelDataType" +export * from "./getModelValidationType" export * from "./metadata" export * from "./Model" export * from "./modelDecorator" diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index 5781ef9c..30fed9ba 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 { AnyType, TypeToData } from "../typeChecking/schemas" import { addHiddenProp, failure, @@ -11,11 +12,23 @@ import { import { AnyModel, ModelClass, modelInitializedSymbol } from "./BaseModel" import { modelTypeKey } from "./metadata" import { modelInfoByClass, modelInfoByName } from "./modelInfo" -import { modelUnwrappedClassSymbol } from "./modelSymbols" +import { modelUnwrappedClassSymbol, modelValidationTypeCheckerSymbol } from "./modelSymbols" import { assertIsModelClass } from "./utils" const { makeObservable } = require("mobx") +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. @@ -23,11 +36,19 @@ const { makeObservable } = require("mobx") * @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. */ -export const model = (name: string) => >(clazz: MC): MC => { - return internalModel(name)(clazz) +export const model = (name: string, type?: T) => < + MC extends ModelClass>> +>( + clazz: MC +): MC => { + return internalModel(name, type)(clazz) } -const internalModel = (name: string) => >(clazz: MC): MC => { +const internalModel = (name: string, type?: T) => < + MC extends ModelClass>> +>( + clazz: MC +): MC => { assertIsModelClass(clazz, "a model class") if (modelInfoByName[name]) { @@ -90,6 +111,7 @@ const internalModel = (name: string) => >(clazz: clazz.toString = () => `class ${clazz.name}#${name}` ;(clazz as any)[modelTypeKey] = name + ;(clazz as any)[modelValidationTypeCheckerSymbol] = type // this also gives access to modelInitializersSymbol, modelPropertiesSymbol, modelDataTypeCheckerSymbol Object.setPrototypeOf(newClazz, clazz) diff --git a/packages/lib/src/model/modelSymbols.ts b/packages/lib/src/model/modelSymbols.ts index 50887ac4..0c8b2b7b 100644 --- a/packages/lib/src/model/modelSymbols.ts +++ b/packages/lib/src/model/modelSymbols.ts @@ -1,2 +1,3 @@ export const modelDataTypeCheckerSymbol = Symbol("modelDataTypeChecker") +export const modelValidationTypeCheckerSymbol = Symbol("modelValidationTypeChecker") export const modelUnwrappedClassSymbol = Symbol("modelUnwrappedClass") diff --git a/packages/lib/test/typeChecking/typeChecking.test.ts b/packages/lib/test/typeChecking/typeChecking.test.ts index d1b4eb31..37fe037d 100644 --- a/packages/lib/test/typeChecking/typeChecking.test.ts +++ b/packages/lib/test/typeChecking/typeChecking.test.ts @@ -1253,3 +1253,69 @@ test("syntax sugar for primitives in tProp", () => { expectTypeCheckFail(type, ss, ["or"], "string | number | boolean") ss.setOr(5) }) + +describe("model type validation", () => { + test("simple", () => { + @model("ValidatedModel/simple", types.object(() => ({ value: types.number }))) + class M extends Model({ + value: prop(), + }) {} + + expect(new M({ value: 10 }).typeValidate()).toBeNull() + }) + + 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(), + }) {} + + const m1 = new M({ kind: "float", value: 10.5 }) + expect(m1.typeValidate()).toBeNull() + + const m2 = new M({ kind: "int", value: 10 }) + expect(m2.typeValidate()).toBeNull() + + const m3 = new M({ kind: "int", value: 10.5 }) + expect(m3.typeValidate()).toEqual( + new TypeCheckError( + [], + `{ kind: "float"; value: number; } | { kind: "int"; value: integer; }`, + m3 + ) + ) + }) + + test("class property", () => { + @model("ValidatedModel/class-property", types.object(() => ({ value: types.number }))) + class M extends Model({}) { + value: number = 10 + } + + expect(new M({}).typeValidate()).toBeNull() + }) + + test("computed property", () => { + @model("ValidatedModel/computed-property", types.object(() => ({ value: types.number }))) + class M extends Model({}) { + get value(): number { + return 10 + } + } + + expect(new M({}).typeValidate()).toBeNull() + }) +}) From 6f9c17a2606eee5e8b6af66011f420d94c97d42e Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Wed, 23 Dec 2020 17:50:56 +0100 Subject: [PATCH 02/11] Use AnyStandardType instead of AnyType --- packages/lib/src/model/modelDecorator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index 30fed9ba..be438ca7 100644 --- a/packages/lib/src/model/modelDecorator.ts +++ b/packages/lib/src/model/modelDecorator.ts @@ -1,13 +1,13 @@ import { HookAction } from "../action/hookActions" import { wrapModelMethodInActionIfNeeded } from "../action/wrapInAction" import { getGlobalConfig } from "../globalConfig" -import { AnyType, TypeToData } from "../typeChecking/schemas" +import { AnyStandardType, TypeToData } from "../typeChecking/schemas" import { addHiddenProp, failure, getMobxVersion, logWarning, - runLateInitializationFunctions, + runLateInitializationFunctions } from "../utils" import { AnyModel, ModelClass, modelInitializedSymbol } from "./BaseModel" import { modelTypeKey } from "./metadata" @@ -36,7 +36,7 @@ type Unionize = { * @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. */ -export const model = (name: string, type?: T) => < +export const model = (name: string, type?: T) => < MC extends ModelClass>> >( clazz: MC @@ -44,7 +44,7 @@ export const model = (name: string, type?: T) => < return internalModel(name, type)(clazz) } -const internalModel = (name: string, type?: T) => < +const internalModel = (name: string, type?: T) => < MC extends ModelClass>> >( clazz: MC From 664062a7d69261ccff2c81fb624ece97f2faf5f5 Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Wed, 23 Dec 2020 17:52:54 +0100 Subject: [PATCH 03/11] Add JSDoc typeparam comment --- packages/lib/src/model/modelDecorator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index be438ca7..98bb0350 100644 --- a/packages/lib/src/model/modelDecorator.ts +++ b/packages/lib/src/model/modelDecorator.ts @@ -33,6 +33,7 @@ type Unionize = { * Decorator that marks this class (which MUST inherit from the `Model` abstract class) * as a model. * + * @typeparam T Data 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. */ From 56ef9a093974dc92dea2f75d4aec6b1d301720d3 Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Wed, 23 Dec 2020 18:16:36 +0100 Subject: [PATCH 04/11] Rename typeparam to be consistent with fnModel --- packages/lib/src/model/modelDecorator.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index 98bb0350..25cc8ac6 100644 --- a/packages/lib/src/model/modelDecorator.ts +++ b/packages/lib/src/model/modelDecorator.ts @@ -33,20 +33,20 @@ type Unionize = { * Decorator that marks this class (which MUST inherit from the `Model` abstract class) * as a model. * - * @typeparam T Data type. + * @typeparam DataType Data 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. */ -export const model = (name: string, type?: T) => < - MC extends ModelClass>> +export const model = (name: string, dataType?: DataType) => < + MC extends ModelClass>> >( clazz: MC ): MC => { - return internalModel(name, type)(clazz) + return internalModel(name, dataType)(clazz) } -const internalModel = (name: string, type?: T) => < - MC extends ModelClass>> +const internalModel = (name: string, dataType?: DataType) => < + MC extends ModelClass>> >( clazz: MC ): MC => { @@ -112,7 +112,7 @@ const internalModel = (name: string, type?: T) => < clazz.toString = () => `class ${clazz.name}#${name}` ;(clazz as any)[modelTypeKey] = name - ;(clazz as any)[modelValidationTypeCheckerSymbol] = type + ;(clazz as any)[modelValidationTypeCheckerSymbol] = dataType // this also gives access to modelInitializersSymbol, modelPropertiesSymbol, modelDataTypeCheckerSymbol Object.setPrototypeOf(newClazz, clazz) From 86c1972f83e69c25893c95962f851cd2d498219e Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sat, 26 Dec 2020 17:03:07 +0100 Subject: [PATCH 05/11] Add support for runtime validation with types.model A minor breaking change is introduced in the `TypeError` error path which now contains model interim data object path segments (`$`) explicitly. This is also more consistent with runtime types defined via `tProp` because type-checks using those types are applied to the interim data object props (i.e. before prop transforms are applied, if applicable). The new runtime validation type specified via the `@model` decorator targets model instance props unless the `types.object` type describing the model instance contains a key `$`. --- packages/lib/src/model/BaseModel.ts | 12 ---- packages/lib/src/parent/path.ts | 11 ++-- packages/lib/src/typeChecking/model.ts | 18 ++++-- packages/lib/test/model/subclassing.test.ts | 4 +- .../test/typeChecking/typeChecking.test.ts | 64 +++++++++++++------ 5 files changed, 66 insertions(+), 43 deletions(-) diff --git a/packages/lib/src/model/BaseModel.ts b/packages/lib/src/model/BaseModel.ts index c36ae623..f93da3f5 100644 --- a/packages/lib/src/model/BaseModel.ts +++ b/packages/lib/src/model/BaseModel.ts @@ -8,7 +8,6 @@ import { typesModel } from "../typeChecking/model" import { typeCheck } from "../typeChecking/typeCheck" import { TypeCheckError } from "../typeChecking/TypeCheckError" import { assertIsObject } from "../utils" -import { getModelValidationType } from "./getModelValidationType" import { modelIdKey, modelTypeKey } from "./metadata" import { ModelConstructorOptions } from "./ModelConstructorOptions" import { modelInfoByClass } from "./modelInfo" @@ -129,17 +128,6 @@ export abstract class BaseModel< return typeCheck(type, this as any) } - /** - * Performs type validation over the model instance. - * For this to work a validation type has do be declared in the model decorator. - * - * @returns A `TypeCheckError` or `null` if there is no error. - */ - typeValidate(): TypeCheckError | null { - const type = getModelValidationType(this.constructor as any) - return typeCheck(type, this as any) - } - /** * Creates an instance of Model. */ 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/typeChecking/model.ts b/packages/lib/src/typeChecking/model.ts index 0f782ace..275d3eb4 100644 --- a/packages/lib/src/typeChecking/model.ts +++ b/packages/lib/src/typeChecking/model.ts @@ -1,6 +1,7 @@ import { O } from "ts-toolbelt" import { AnyModel, ModelClass } from "../model/BaseModel" import { getModelDataType } from "../model/getModelDataType" +import { getModelValidationType } from "../model/getModelValidationType" import { modelInfoByClass } from "../model/modelInfo" import { getInternalModelClassPropsInfo } from "../model/modelPropsInfo" import { noDefaultValue } from "../model/prop" @@ -56,7 +57,8 @@ export function typesModel(modelClass: object): IdentityType { } const dataTypeChecker = getModelDataType(value) - if (!dataTypeChecker) { + const validationTypeChecker = getModelValidationType(value) + if (!dataTypeChecker && !validationTypeChecker) { throw failure( `type checking cannot be performed over model of type '${ modelInfo.name @@ -66,9 +68,17 @@ export function typesModel(modelClass: object): IdentityType { ) } - const resolvedTc = resolveTypeChecker(dataTypeChecker) - if (!resolvedTc.unchecked) { - return resolvedTc.check(value.$, path) + if (dataTypeChecker) { + const resolvedTc = resolveTypeChecker(dataTypeChecker) + if (!resolvedTc.unchecked) { + return resolvedTc.check(value.$, [...path, "$"]) + } + } + if (validationTypeChecker) { + const resolvedTc = resolveTypeChecker(validationTypeChecker) + if (!resolvedTc.unchecked) { + return resolvedTc.check(value, path) + } } return null diff --git a/packages/lib/test/model/subclassing.test.ts b/packages/lib/test/model/subclassing.test.ts index 9da57bf0..dda1cfdb 100644 --- a/packages/lib/test/model/subclassing.test.ts +++ b/packages/lib/test/model/subclassing.test.ts @@ -329,11 +329,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/typeChecking.test.ts b/packages/lib/test/typeChecking/typeChecking.test.ts index 5c4ceec6..f5283a9e 100644 --- a/packages/lib/test/typeChecking/typeChecking.test.ts +++ b/packages/lib/test/typeChecking/typeChecking.test.ts @@ -106,7 +106,7 @@ function expectTypeCheckOk(t: T, val: TypeToData) { function expectTypeCheckFail(t: T, val: any, path: Path, expected: string) { const err = typeCheck(t, val) - const { value: actualValue } = resolvePath(val, path) + const { value: actualValue } = resolvePath(val, path, true) expect(err).toEqual(new TypeCheckError(path, expected, actualValue)) } @@ -448,8 +448,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(new TypeCheckError(["$", "x"], "number", "10")) const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(M) @@ -528,7 +528,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", () => { @@ -787,7 +787,7 @@ test("recursive model", () => { expectTypeCheckOk(type, mr) mr.setRec("5" as any) - expectTypeCheckFail(type, mr, ["rec"], "Model(MR) | undefined") + expectTypeCheckFail(type, mr, ["$", "rec"], "Model(MR) | undefined") const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(MR) @@ -837,7 +837,7 @@ test("cross referenced model", () => { expectTypeCheckOk(type, ma) ma.b!.setA("5" as any) - expectTypeCheckFail(type, ma, ["b"], "Model(MB) | undefined") + expectTypeCheckFail(type, ma, ["$", "b"], "Model(MB) | undefined") const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(MA) @@ -1229,39 +1229,39 @@ 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, ["$", "or"], "string | number | boolean") ss.setOr(5) }) @@ -1272,7 +1272,7 @@ describe("model type validation", () => { value: prop(), }) {} - expect(new M({ value: 10 }).typeValidate()).toBeNull() + expect(new M({ value: 10 }).typeCheck()).toBeNull() }) test("complex - union", () => { @@ -1295,13 +1295,13 @@ describe("model type validation", () => { }) {} const m1 = new M({ kind: "float", value: 10.5 }) - expect(m1.typeValidate()).toBeNull() + expect(m1.typeCheck()).toBeNull() const m2 = new M({ kind: "int", value: 10 }) - expect(m2.typeValidate()).toBeNull() + expect(m2.typeCheck()).toBeNull() const m3 = new M({ kind: "int", value: 10.5 }) - expect(m3.typeValidate()).toEqual( + expect(m3.typeCheck()).toEqual( new TypeCheckError( [], `{ kind: "float"; value: number; } | { kind: "int"; value: integer; }`, @@ -1316,7 +1316,7 @@ describe("model type validation", () => { value: number = 10 } - expect(new M({}).typeValidate()).toBeNull() + expect(new M({}).typeCheck()).toBeNull() }) test("computed property", () => { @@ -1327,6 +1327,28 @@ describe("model type validation", () => { } } - expect(new M({}).typeValidate()).toBeNull() + expect(new M({}).typeCheck()).toBeNull() + }) + + 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( + new TypeCheckError(["child", "value"], `integer`, 10.5) + ) }) }) From 870fefd24fe8db1709b8f2b0ec78e335e52c158c Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Wed, 6 Jan 2021 21:13:53 +0100 Subject: [PATCH 06/11] Add reactive validation type-checking context --- packages/lib/src/globalConfig/globalConfig.ts | 6 + packages/lib/src/model/newModel.ts | 11 ++ packages/lib/src/typeChecking/index.ts | 1 + packages/lib/src/typeChecking/validation.ts | 33 ++++ .../test/typeChecking/typeChecking.test.ts | 171 ++++++++++++++++-- 5 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 packages/lib/src/typeChecking/validation.ts diff --git a/packages/lib/src/globalConfig/globalConfig.ts b/packages/lib/src/globalConfig/globalConfig.ts index 1d2cf825..3f8f1d6a 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 $modelId. */ @@ -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/newModel.ts b/packages/lib/src/model/newModel.ts index a8eb0373..9f774cb0 100644 --- a/packages/lib/src/model/newModel.ts +++ b/packages/lib/src/model/newModel.ts @@ -1,8 +1,10 @@ import { action, set } from "mobx" import { O } from "ts-toolbelt" import { getGlobalConfig, isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig" +import { getParent } from "../parent/path" import { tweakModel } from "../tweaker/tweakModel" import { tweakPlainObject } from "../tweaker/tweakPlainObject" +import { validationContext } from "../typeChecking/validation" import { failure, inDevMode, makePropReadonly } from "../utils" import { AnyModel, ModelPropsCreationData } from "./BaseModel" import { getModelDataType } from "./getModelDataType" @@ -128,6 +130,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/typeChecking/index.ts b/packages/lib/src/typeChecking/index.ts index 5e47adc7..6af11d68 100644 --- a/packages/lib/src/typeChecking/index.ts +++ b/packages/lib/src/typeChecking/index.ts @@ -3,3 +3,4 @@ export * from "./tProp" export * from "./typeCheck" export * from "./TypeCheckError" export * from "./types" +export * from "./validation" diff --git a/packages/lib/src/typeChecking/validation.ts b/packages/lib/src/typeChecking/validation.ts new file mode 100644 index 00000000..e9f4489a --- /dev/null +++ b/packages/lib/src/typeChecking/validation.ts @@ -0,0 +1,33 @@ +import { createContext } from "../context" +import { getRootPath } from "../parent/path" +import { deepEquals } from "../treeUtils/deepEquals" +import { TypeCheckError } from "./TypeCheckError" + +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 `TypeCheckError` if there is an error, `null` if there is no error, and `undefined` if + * model type validation is not enabled in the global config. + */ +export function getValidationResult(node: object): TypeCheckError | null | undefined { + const error = validationContext.get(node) + + if (!error) { + return error + } + + const nodePath = getRootPath(node).path + if (deepEquals(nodePath, error.path.slice(0, nodePath.length))) { + return new TypeCheckError( + error.path.slice(getRootPath(node).path.length), + error.expectedTypeName, + error.actualValue + ) + } + + return null +} diff --git a/packages/lib/test/typeChecking/typeChecking.test.ts b/packages/lib/test/typeChecking/typeChecking.test.ts index f5283a9e..855080b2 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, @@ -13,6 +13,7 @@ import { frozen, FrozenTypeInfo, getTypeInfo, + getValidationResult, LiteralTypeInfo, model, Model, @@ -1266,13 +1267,24 @@ test("syntax sugar for primitives in tProp", () => { }) describe("model type validation", () => { + beforeAll(() => { + setGlobalConfig({ modelAutoTypeValidation: true }) + }) + + afterAll(() => { + setGlobalConfig({ modelAutoTypeValidation: false }) + }) + test("simple", () => { - @model("ValidatedModel/simple", types.object(() => ({ value: types.number }))) + @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( + new TypeCheckError(["value"], "integer", 10.5) + ) }) test("complex - union", () => { @@ -1294,18 +1306,14 @@ describe("model type validation", () => { value: prop(), }) {} - const m1 = new M({ kind: "float", value: 10.5 }) - expect(m1.typeCheck()).toBeNull() - - const m2 = new M({ kind: "int", value: 10 }) - expect(m2.typeCheck()).toBeNull() - - const m3 = new M({ kind: "int", value: 10.5 }) - expect(m3.typeCheck()).toEqual( + 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( new TypeCheckError( [], `{ kind: "float"; value: number; } | { kind: "int"; value: integer; }`, - m3 + m ) ) }) @@ -1320,14 +1328,23 @@ describe("model type validation", () => { }) test("computed property", () => { - @model("ValidatedModel/computed-property", types.object(() => ({ value: types.number }))) - class M extends Model({}) { - get value(): number { - return 10 + @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({}).typeCheck()).toBeNull() + expect(new M({ value: 10 }).typeCheck()).toBeNull() + expect(new M({ value: 10.5 }).typeCheck()).toEqual( + new TypeCheckError(["computedValue"], "integer", 10.5) + ) }) test("child model", () => { @@ -1348,7 +1365,127 @@ describe("model type validation", () => { const parent = new Parent({ child: new Child({ value: 10.5 }) }) expect(parent.typeCheck()).toEqual( - new TypeCheckError(["child", "value"], `integer`, 10.5) + new TypeCheckError(["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, + new TypeCheckError(["value"], "integer", 10.5), + new TypeCheckError(["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, + new TypeCheckError(["child1", "value"], "integer", 10.5), + new TypeCheckError(["child1", "value"], "integer", 11.5), + null, + ], + child1: [ + null, + new TypeCheckError(["value"], "integer", 10.5), + new TypeCheckError(["value"], "integer", 11.5), + null, + ], + child2: [null], + }) }) }) From 5e46a605f0061bbda72e96c04ddb69f8d3351bed Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sat, 9 Jan 2021 22:49:07 +0100 Subject: [PATCH 07/11] Collect all type-check errors with accurate error relationships BREAKING CHANGE: The type-check error representation is extended to support logical expressions of errors to acturately express their relationships. Now, type-checking returns all errors instead of only the first error. --- packages/lib/package.json | 2 + packages/lib/src/fnModel/fnModel.ts | 7 +- packages/lib/src/model/BaseModel.ts | 6 +- packages/lib/src/model/newModel.ts | 3 +- packages/lib/src/tweaker/typeChecking.ts | 3 +- .../lib/src/typeChecking/TypeCheckError.ts | 36 --- .../lib/src/typeChecking/TypeCheckErrors.ts | 187 ++++++++++++ packages/lib/src/typeChecking/TypeChecker.ts | 17 +- packages/lib/src/typeChecking/array.ts | 10 +- packages/lib/src/typeChecking/arraySet.ts | 4 +- packages/lib/src/typeChecking/index.ts | 2 +- packages/lib/src/typeChecking/model.ts | 19 +- packages/lib/src/typeChecking/object.ts | 11 +- packages/lib/src/typeChecking/objectMap.ts | 4 +- packages/lib/src/typeChecking/or.ts | 18 +- packages/lib/src/typeChecking/primitives.ts | 11 +- packages/lib/src/typeChecking/record.ts | 10 +- packages/lib/src/typeChecking/ref.ts | 4 +- packages/lib/src/typeChecking/refinement.ts | 12 +- packages/lib/src/typeChecking/tuple.ts | 11 +- packages/lib/src/typeChecking/typeCheck.ts | 11 +- packages/lib/src/typeChecking/validation.ts | 30 +- packages/lib/src/utils/drawTree.ts | 33 +++ packages/lib/src/utils/index.ts | 12 +- packages/lib/src/utils/types.ts | 18 ++ .../test/typeChecking/TypeCheckErrors.test.ts | 230 +++++++++++++++ .../test/typeChecking/typeChecking.test.ts | 267 +++++++++++++++--- yarn.lock | 5 + 28 files changed, 819 insertions(+), 164 deletions(-) delete mode 100644 packages/lib/src/typeChecking/TypeCheckError.ts create mode 100644 packages/lib/src/typeChecking/TypeCheckErrors.ts create mode 100644 packages/lib/src/utils/drawTree.ts create mode 100644 packages/lib/test/typeChecking/TypeCheckErrors.test.ts diff --git a/packages/lib/package.json b/packages/lib/package.json index bd51db54..c44e470d 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -45,7 +45,9 @@ "mobx": "^6.0.0 || ^5.0.0 || ^4.0.0" }, "devDependencies": { + "@types/dedent": "^0.7.0", "@types/jest": "^26.0.0", + "dedent": "^0.7.0", "mobx": "^6.0.1", "netlify-cli": "^2.17.0", "shx": "^0.3.2", 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/model/BaseModel.ts b/packages/lib/src/model/BaseModel.ts index f93da3f5..991b5864 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 { modelIdKey, modelTypeKey } from "./metadata" import { ModelConstructorOptions } from "./ModelConstructorOptions" @@ -121,9 +121,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/newModel.ts b/packages/lib/src/model/newModel.ts index 9f774cb0..e0c31f0e 100644 --- a/packages/lib/src/model/newModel.ts +++ b/packages/lib/src/model/newModel.ts @@ -4,6 +4,7 @@ import { getGlobalConfig, isModelAutoTypeCheckingEnabled } from "../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" @@ -116,7 +117,7 @@ export const internalNewModel = action( if (isModelAutoTypeCheckingEnabled() && getModelDataType(modelClass)) { const err = modelObj.typeCheck() if (err) { - err.throw(modelObj) + throwTypeCheckErrors(err, modelObj) } } diff --git a/packages/lib/src/tweaker/typeChecking.ts b/packages/lib/src/tweaker/typeChecking.ts index a55ecf99..8599301d 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 060e88dc..c88899e9 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 6af11d68..6aa88e71 100644 --- a/packages/lib/src/typeChecking/index.ts +++ b/packages/lib/src/typeChecking/index.ts @@ -1,6 +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 275d3eb4..045f35fe 100644 --- a/packages/lib/src/typeChecking/model.ts +++ b/packages/lib/src/typeChecking/model.ts @@ -10,7 +10,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>() @@ -53,7 +53,7 @@ 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 = getModelDataType(value) @@ -68,19 +68,30 @@ export function typesModel(modelClass: object): IdentityType { ) } + let dataErrors: TypeCheckErrors | null | undefined if (dataTypeChecker) { const resolvedTc = resolveTypeChecker(dataTypeChecker) if (!resolvedTc.unchecked) { - return resolvedTc.check(value.$, [...path, "$"]) + dataErrors = resolvedTc.check(value.$, [...path, "$"]) } } + + let validationErrors: TypeCheckErrors | null | undefined if (validationTypeChecker) { const resolvedTc = resolveTypeChecker(validationTypeChecker) if (!resolvedTc.unchecked) { - return resolvedTc.check(value, path) + 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 }, () => typeName, 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 06aab5d5..244d7893 100644 --- a/packages/lib/src/typeChecking/typeCheck.ts +++ b/packages/lib/src/typeChecking/typeCheck.ts @@ -1,16 +1,19 @@ 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. * - * @typename S Type. + * @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 index e9f4489a..e140043f 100644 --- a/packages/lib/src/typeChecking/validation.ts +++ b/packages/lib/src/typeChecking/validation.ts @@ -1,33 +1,29 @@ +import fastDeepEqual from "fast-deep-equal/es6" import { createContext } from "../context" import { getRootPath } from "../parent/path" -import { deepEquals } from "../treeUtils/deepEquals" -import { TypeCheckError } from "./TypeCheckError" +import { transformTypeCheckErrors, TypeCheckErrors } from "./TypeCheckErrors" -export const validationContext = createContext() +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 `TypeCheckError` if there is an error, `null` if there is no error, and `undefined` if + * @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): TypeCheckError | null | undefined { - const error = validationContext.get(node) +export function getValidationResult(node: object): TypeCheckErrors | null | undefined { + const errors = validationContext.get(node) - if (!error) { - return error + if (!errors) { + return errors } const nodePath = getRootPath(node).path - if (deepEquals(nodePath, error.path.slice(0, nodePath.length))) { - return new TypeCheckError( - error.path.slice(getRootPath(node).path.length), - error.expectedTypeName, - error.actualValue - ) - } - - return null + 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/typeChecking/TypeCheckErrors.test.ts b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts new file mode 100644 index 00000000..45705bda --- /dev/null +++ b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts @@ -0,0 +1,230 @@ +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: TypeCheckErrors = { + path: [], + expectedTypeName: "number", + actualValue: "abc", + } + expect(mergeTypeCheckErrors("and", [error])).toStrictEqual(error) + }) + + test("two arguments (not same operator)", () => { + const errors: ReadonlyNonEmptyArray = [ + { + path: ["x"], + expectedTypeName: "number", + actualValue: "x", + }, + { + op: "or", + args: [ + { + path: ["y"], + expectedTypeName: "number", + actualValue: "y", + }, + { + path: ["y"], + expectedTypeName: "undefined", + actualValue: "y", + }, + ], + }, + ] + expect(mergeTypeCheckErrors("and", errors)).toStrictEqual({ op: "and", args: errors }) + }) + + test("two arguments (same operator)", () => { + expect( + mergeTypeCheckErrors("and", [ + { + path: ["x"], + expectedTypeName: "number", + actualValue: "x", + }, + { + op: "and", + args: [ + { + path: ["y", "a"], + expectedTypeName: "number", + actualValue: "a", + }, + { + path: ["y", "b"], + expectedTypeName: "number", + actualValue: "b", + }, + ], + }, + ]) + ).toStrictEqual({ + op: "and", + args: [ + { + path: ["x"], + expectedTypeName: "number", + actualValue: "x", + }, + { + path: ["y", "a"], + expectedTypeName: "number", + actualValue: "a", + }, + { + path: ["y", "b"], + expectedTypeName: "number", + actualValue: "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( + { + path: [], + expectedTypeName: "number", + actualValue: "abc", + }, + "abc" + ) + ).toBe("[/] Expected: number") + }) + + test("error expression - simple", () => { + expect( + getTypeCheckErrorMessage( + { + op: "and", + args: [ + { + path: ["x"], + expectedTypeName: "number", + actualValue: "x", + }, + { + path: ["y"], + expectedTypeName: "number", + actualValue: "y", + }, + ], + }, + { + x: "x", + y: "y", + } + ) + ).toBe( + dedent` + AND + ├─ [/x] Expected: number + └─ [/y] Expected: number + ` + ) + }) + + test("error expression - complex", () => { + expect( + getTypeCheckErrorMessage( + { + op: "or", + args: [ + { + op: "and", + args: [ + { + path: ["kind"], + expectedTypeName: '"float"', + actualValue: '"boolean"', + }, + { + path: ["value"], + expectedTypeName: "number", + actualValue: true, + }, + ], + }, + { + op: "and", + args: [ + { + path: ["kind"], + expectedTypeName: '"int"', + actualValue: '"boolean"', + }, + { + path: ["value"], + expectedTypeName: "integer", + actualValue: 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 855080b2..be8af316 100644 --- a/packages/lib/test/typeChecking/typeChecking.test.ts +++ b/packages/lib/test/typeChecking/typeChecking.test.ts @@ -9,12 +9,14 @@ import { ArraySetTypeInfo, ArrayTypeInfo, BooleanTypeInfo, + createTypeCheckError, customRef, frozen, FrozenTypeInfo, getTypeInfo, getValidationResult, LiteralTypeInfo, + mergeTypeCheckErrors, model, Model, modelAction, @@ -42,7 +44,7 @@ import { tProp, TupleTypeInfo, typeCheck, - TypeCheckError, + TypeCheckErrors, TypeInfo, types, TypeToData, @@ -105,10 +107,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, true) - 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 +260,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 +280,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 +300,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 +320,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 +436,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" }) @@ -450,7 +499,7 @@ test("model", () => { 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")) + expect(m.typeCheck()).toEqual(createTypeCheckError(["$", "x"], "number", "10")) const typeInfo = expectValidTypeInfo(type, ModelTypeInfo) expect(typeInfo.modelClass).toBe(M) @@ -580,6 +629,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(() => ({ @@ -623,6 +696,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, @@ -643,6 +728,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, @@ -655,9 +752,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]) @@ -697,12 +807,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) @@ -735,12 +855,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 }), + ]) ) { @@ -788,7 +927,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) @@ -838,7 +984,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) @@ -921,7 +1075,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) @@ -944,7 +1105,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) @@ -969,7 +1137,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) @@ -1043,7 +1219,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 }) @@ -1262,7 +1438,15 @@ test("syntax sugar for primitives in tProp", () => { 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) }) @@ -1283,7 +1467,7 @@ describe("model type validation", () => { expect(new M({ value: 10 }).typeCheck()).toBeNull() expect(new M({ value: 10.5 }).typeCheck()).toEqual( - new TypeCheckError(["value"], "integer", 10.5) + createTypeCheckError(["value"], "integer", 10.5) ) }) @@ -1310,11 +1494,10 @@ describe("model type validation", () => { expect(new M({ kind: "int", value: 10 }).typeCheck()).toBeNull() const m = new M({ kind: "int", value: 10.5 }) expect(m.typeCheck()).toEqual( - new TypeCheckError( - [], - `{ kind: "float"; value: number; } | { kind: "int"; value: integer; }`, - m - ) + mergeTypeCheckErrors("or", [ + createTypeCheckError(["kind"], `"float"`, "int"), + createTypeCheckError(["value"], `integer`, 10.5), + ]) ) }) @@ -1343,7 +1526,7 @@ describe("model type validation", () => { expect(new M({ value: 10 }).typeCheck()).toBeNull() expect(new M({ value: 10.5 }).typeCheck()).toEqual( - new TypeCheckError(["computedValue"], "integer", 10.5) + createTypeCheckError(["computedValue"], "integer", 10.5) ) }) @@ -1365,7 +1548,7 @@ describe("model type validation", () => { const parent = new Parent({ child: new Child({ value: 10.5 }) }) expect(parent.typeCheck()).toEqual( - new TypeCheckError(["child", "value"], "integer", 10.5) + createTypeCheckError(["child", "value"], "integer", 10.5) ) }) @@ -1382,7 +1565,7 @@ describe("model type validation", () => { const m = new M({ value: 10 }) - const errors: Array = [] + const errors: Array = [] autoDispose( reaction( () => getValidationResult(m), @@ -1399,8 +1582,8 @@ describe("model type validation", () => { expect(errors).toEqual([ null, - new TypeCheckError(["value"], "integer", 10.5), - new TypeCheckError(["value"], "integer", 11.5), + createTypeCheckError(["value"], "integer", 10.5), + createTypeCheckError(["value"], "integer", 11.5), null, ]) }) @@ -1434,7 +1617,7 @@ describe("model type validation", () => { const errors: Record< "parent" | "child1" | "child2", - Array + Array > = { parent: [], child1: [], @@ -1475,14 +1658,14 @@ describe("model type validation", () => { expect(errors).toEqual({ parent: [ null, - new TypeCheckError(["child1", "value"], "integer", 10.5), - new TypeCheckError(["child1", "value"], "integer", 11.5), + createTypeCheckError(["child1", "value"], "integer", 10.5), + createTypeCheckError(["child1", "value"], "integer", 11.5), null, ], child1: [ null, - new TypeCheckError(["value"], "integer", 10.5), - new TypeCheckError(["value"], "integer", 11.5), + createTypeCheckError(["value"], "integer", 10.5), + createTypeCheckError(["value"], "integer", 11.5), null, ], child2: [null], diff --git a/yarn.lock b/yarn.lock index 949cbc01..a3b4c85f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3078,6 +3078,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" From 7c94605442f47098aa84c91d4eef4665b9818042 Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sun, 10 Jan 2021 16:21:17 +0100 Subject: [PATCH 08/11] Use factories instead of creating error objects manually in tests --- .../test/typeChecking/TypeCheckErrors.test.ts | 160 ++++-------------- 1 file changed, 35 insertions(+), 125 deletions(-) diff --git a/packages/lib/test/typeChecking/TypeCheckErrors.test.ts b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts index 45705bda..a0c2b0e7 100644 --- a/packages/lib/test/typeChecking/TypeCheckErrors.test.ts +++ b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts @@ -32,36 +32,17 @@ test("isTypeCheckErrorExpression", () => { describe("mergeTypeCheckErrors", () => { test("one argument", () => { - const error: TypeCheckErrors = { - path: [], - expectedTypeName: "number", - actualValue: "abc", - } + const error = createTypeCheckError([], "number", "abc") expect(mergeTypeCheckErrors("and", [error])).toStrictEqual(error) }) test("two arguments (not same operator)", () => { const errors: ReadonlyNonEmptyArray = [ - { - path: ["x"], - expectedTypeName: "number", - actualValue: "x", - }, - { - op: "or", - args: [ - { - path: ["y"], - expectedTypeName: "number", - actualValue: "y", - }, - { - path: ["y"], - expectedTypeName: "undefined", - actualValue: "y", - }, - ], - }, + createTypeCheckError(["x"], "number", "x"), + mergeTypeCheckErrors("or", [ + createTypeCheckError(["y"], "number", "y"), + createTypeCheckError(["y"], "undefined", "y"), + ]), ] expect(mergeTypeCheckErrors("and", errors)).toStrictEqual({ op: "and", args: errors }) }) @@ -69,47 +50,19 @@ describe("mergeTypeCheckErrors", () => { test("two arguments (same operator)", () => { expect( mergeTypeCheckErrors("and", [ - { - path: ["x"], - expectedTypeName: "number", - actualValue: "x", - }, - { - op: "and", - args: [ - { - path: ["y", "a"], - expectedTypeName: "number", - actualValue: "a", - }, - { - path: ["y", "b"], - expectedTypeName: "number", - actualValue: "b", - }, - ], - }, + createTypeCheckError(["x"], "number", "x"), + mergeTypeCheckErrors("and", [ + createTypeCheckError(["y", "a"], "number", "a"), + createTypeCheckError(["y", "b"], "number", "b"), + ]), ]) - ).toStrictEqual({ - op: "and", - args: [ - { - path: ["x"], - expectedTypeName: "number", - actualValue: "x", - }, - { - path: ["y", "a"], - expectedTypeName: "number", - actualValue: "a", - }, - { - path: ["y", "b"], - expectedTypeName: "number", - actualValue: "b", - }, - ], - }) + ).toStrictEqual( + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "x"), + createTypeCheckError(["y", "a"], "number", "a"), + createTypeCheckError(["y", "b"], "number", "b"), + ]) + ) }) }) @@ -128,36 +81,18 @@ test("transformTypeCheckErrors", () => { describe("getTypeCheckErrorMessage", () => { test("single error", () => { - expect( - getTypeCheckErrorMessage( - { - path: [], - expectedTypeName: "number", - actualValue: "abc", - }, - "abc" - ) - ).toBe("[/] Expected: number") + expect(getTypeCheckErrorMessage(createTypeCheckError([], "number", "abc"), "abc")).toBe( + "[/] Expected: number" + ) }) test("error expression - simple", () => { expect( getTypeCheckErrorMessage( - { - op: "and", - args: [ - { - path: ["x"], - expectedTypeName: "number", - actualValue: "x", - }, - { - path: ["y"], - expectedTypeName: "number", - actualValue: "y", - }, - ], - }, + mergeTypeCheckErrors("and", [ + createTypeCheckError(["x"], "number", "x"), + createTypeCheckError(["y"], "number", "y"), + ]), { x: "x", y: "y", @@ -175,41 +110,16 @@ describe("getTypeCheckErrorMessage", () => { test("error expression - complex", () => { expect( getTypeCheckErrorMessage( - { - op: "or", - args: [ - { - op: "and", - args: [ - { - path: ["kind"], - expectedTypeName: '"float"', - actualValue: '"boolean"', - }, - { - path: ["value"], - expectedTypeName: "number", - actualValue: true, - }, - ], - }, - { - op: "and", - args: [ - { - path: ["kind"], - expectedTypeName: '"int"', - actualValue: '"boolean"', - }, - { - path: ["value"], - expectedTypeName: "integer", - actualValue: true, - }, - ], - }, - ], - }, + 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, From 3f0fbf4756a971ae81751c1db3129a2f04d79f9c Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sun, 10 Jan 2021 16:26:29 +0100 Subject: [PATCH 09/11] Use toEqual instead of toStrictEqual --- packages/lib/test/typeChecking/TypeCheckErrors.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/lib/test/typeChecking/TypeCheckErrors.test.ts b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts index a0c2b0e7..0fe3ac78 100644 --- a/packages/lib/test/typeChecking/TypeCheckErrors.test.ts +++ b/packages/lib/test/typeChecking/TypeCheckErrors.test.ts @@ -33,7 +33,7 @@ test("isTypeCheckErrorExpression", () => { describe("mergeTypeCheckErrors", () => { test("one argument", () => { const error = createTypeCheckError([], "number", "abc") - expect(mergeTypeCheckErrors("and", [error])).toStrictEqual(error) + expect(mergeTypeCheckErrors("and", [error])).toEqual(error) }) test("two arguments (not same operator)", () => { @@ -44,7 +44,7 @@ describe("mergeTypeCheckErrors", () => { createTypeCheckError(["y"], "undefined", "y"), ]), ] - expect(mergeTypeCheckErrors("and", errors)).toStrictEqual({ op: "and", args: errors }) + expect(mergeTypeCheckErrors("and", errors)).toEqual({ op: "and", args: errors }) }) test("two arguments (same operator)", () => { @@ -56,7 +56,7 @@ describe("mergeTypeCheckErrors", () => { createTypeCheckError(["y", "b"], "number", "b"), ]), ]) - ).toStrictEqual( + ).toEqual( mergeTypeCheckErrors("and", [ createTypeCheckError(["x"], "number", "x"), createTypeCheckError(["y", "a"], "number", "a"), From edb15ef1de464da85d495728c8f0bf43cc0088fe Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sat, 20 Feb 2021 17:32:24 +0100 Subject: [PATCH 10/11] Rename data type to validation type --- packages/lib/src/model/modelDecorator.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index 1595a43e..4f059e7e 100644 --- a/packages/lib/src/model/modelDecorator.ts +++ b/packages/lib/src/model/modelDecorator.ts @@ -32,23 +32,21 @@ type Unionize = { * Decorator that marks this class (which MUST inherit from the `Model` abstract class) * as a model. * - * @typeparam DataType Data type. + * @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. */ -export const model = (name: string, dataType?: DataType) => < - MC extends ModelClass>> ->( - clazz: MC -): MC => { - return internalModel(name, dataType)(clazz) +export const model = ( + name: string, + validationType?: ValidationType +) => >>>(clazz: MC): MC => { + return internalModel(name, validationType)(clazz) } -const internalModel = (name: string, dataType?: DataType) => < - MC extends ModelClass>> ->( - clazz: MC -): MC => { +const internalModel = ( + name: string, + validationType?: ValidationType +) => >>>(clazz: MC): MC => { assertIsModelClass(clazz, "a model class") if (modelInfoByName[name]) { @@ -113,7 +111,7 @@ const internalModel = (name: string, dataType? clazz.toString = () => `class ${clazz.name}#${name}` ;(clazz as any)[modelTypeKey] = name - ;(clazz as any)[modelMetadataSymbol].validationType = dataType + ;(clazz as any)[modelMetadataSymbol].validationType = validationType // this also gives access to modelInitializersSymbol, modelPropertiesSymbol, modelDataTypeCheckerSymbol Object.setPrototypeOf(newClazz, clazz) From 14af1a1b4dac8c296d635e4e6725e7e9518f94ae Mon Sep 17 00:00:00 2001 From: Sigurd Spieckermann Date: Sat, 20 Feb 2021 17:37:37 +0100 Subject: [PATCH 11/11] Add missing param comment --- packages/lib/src/model/modelDecorator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/src/model/modelDecorator.ts b/packages/lib/src/model/modelDecorator.ts index 4f059e7e..83c5cda9 100644 --- a/packages/lib/src/model/modelDecorator.ts +++ b/packages/lib/src/model/modelDecorator.ts @@ -35,6 +35,7 @@ type Unionize = { * @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,