Skip to content

Commit

Permalink
Add runtime type validation
Browse files Browse the repository at this point in the history
  • Loading branch information
sisp committed Dec 12, 2020
1 parent 5af4da1 commit b8e24b1
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 4 deletions.
12 changes: 12 additions & 0 deletions packages/lib/src/model/BaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
*/
Expand Down
24 changes: 24 additions & 0 deletions packages/lib/src/model/getModelValidationType.ts
Original file line number Diff line number Diff line change
@@ -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<AnyModel>
): 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`)
}
}
1 change: 1 addition & 0 deletions packages/lib/src/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./BaseModel"
export * from "./getModelDataType"
export * from "./getModelValidationType"
export * from "./metadata"
export * from "./Model"
export * from "./modelDecorator"
Expand Down
30 changes: 26 additions & 4 deletions packages/lib/src/model/modelDecorator.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,23 +12,43 @@ 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> = T extends unknown ? keyof T : never

type AllValues<T, K extends keyof any> = T extends object
? K extends keyof T
? T[K]
: never
: never

type Unionize<T> = {
[K in AllKeys<T>]: AllValues<T, K>
}

/**
* Decorator that marks this class (which MUST inherit from the `Model` abstract class)
* as a model.
*
* @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) => <MC extends ModelClass<AnyModel>>(clazz: MC): MC => {
return internalModel(name)(clazz)
export const model = <T extends AnyType>(name: string, type?: T) => <
MC extends ModelClass<AnyModel & Unionize<TypeToData<T>>>
>(
clazz: MC
): MC => {
return internalModel(name, type)(clazz)
}

const internalModel = (name: string) => <MC extends ModelClass<AnyModel>>(clazz: MC): MC => {
const internalModel = <T extends AnyType>(name: string, type?: T) => <
MC extends ModelClass<AnyModel & Unionize<TypeToData<T>>>
>(
clazz: MC
): MC => {
assertIsModelClass(clazz, "a model class")

if (modelInfoByName[name]) {
Expand Down Expand Up @@ -90,6 +111,7 @@ const internalModel = (name: string) => <MC extends ModelClass<AnyModel>>(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)
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/model/modelSymbols.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const modelDataTypeCheckerSymbol = Symbol("modelDataTypeChecker")
export const modelValidationTypeCheckerSymbol = Symbol("modelValidationTypeChecker")
export const modelUnwrappedClassSymbol = Symbol("modelUnwrappedClass")
66 changes: 66 additions & 0 deletions packages/lib/test/typeChecking/typeChecking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(),
}) {}

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<number>(),
}) {}

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<number>; }`,
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()
})
})

0 comments on commit b8e24b1

Please sign in to comment.