mobx-keystone
Version:
A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more
226 lines (194 loc) • 6.12 kB
text/typescript
import type { O } from "ts-toolbelt"
import { Frozen } from "../../frozen/Frozen"
import { assertIsFunction, assertIsObject, isObject, lazy } from "../../utils"
import { getTypeInfo } from "../getTypeInfo"
import { resolveStandardType, resolveTypeChecker } from "../resolveTypeChecker"
import type {
AnyStandardType,
AnyType,
ModelType,
ObjectTypeFunction,
TypeToData,
} from "../schemas"
import {
lateTypeChecker,
LateTypeChecker,
TypeChecker,
TypeCheckerBaseType,
TypeInfo,
TypeInfoGen,
} from "../TypeChecker"
import { TypeCheckError } from "../TypeCheckError"
function typesObjectHelper<S>(objFn: S, frozen: boolean, typeInfoGen: TypeInfoGen): S {
assertIsFunction(objFn, "objFn")
return lateTypeChecker(() => {
const objectSchema: Record<string, TypeChecker | LateTypeChecker> = objFn()
assertIsObject(objectSchema, "objectSchema")
const schemaEntries = Object.entries(objectSchema)
const getTypeName = (...recursiveTypeCheckers: TypeChecker[]) => {
const propsMsg: string[] = []
for (const [k, unresolvedTc] of schemaEntries) {
const tc = resolveTypeChecker(unresolvedTc)
let propTypename = "..."
if (!recursiveTypeCheckers.includes(tc)) {
propTypename = tc.getTypeName(...recursiveTypeCheckers, tc)
}
propsMsg.push(`${k}: ${propTypename};`)
}
return `{ ${propsMsg.join(" ")} }`
}
const applySnapshotProcessor = (obj: Record<string, unknown>, mode: "from" | "to") => {
const newObj: typeof obj = {}
// note: we allow excess properties when checking objects
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
const unresolvedTc = objectSchema[k]
if (unresolvedTc) {
const tc = resolveTypeChecker(unresolvedTc)
newObj[k] =
mode === "from" ? tc.fromSnapshotProcessor(obj[k]) : tc.toSnapshotProcessor(obj[k])
} else {
// unknown prop, copy as is
newObj[k] = obj[k]
}
}
return newObj
}
const thisTc: TypeChecker = new TypeChecker(
TypeCheckerBaseType.Object,
(obj, path, typeCheckedValue) => {
if (!isObject(obj) || (frozen && !(obj instanceof Frozen))) {
return new TypeCheckError(path, getTypeName(thisTc), obj, typeCheckedValue)
}
// note: we allow excess properties when checking objects
for (const [k, unresolvedTc] of schemaEntries) {
const tc = resolveTypeChecker(unresolvedTc)
const objVal = obj[k]
const valueError = tc.check(objVal, [...path, k], typeCheckedValue)
if (valueError) {
return valueError
}
}
return null
},
getTypeName,
typeInfoGen,
(obj) => {
if (!isObject(obj)) {
return null
}
// note: we allow excess properties when checking objects
for (const [k, unresolvedTc] of schemaEntries) {
const tc = resolveTypeChecker(unresolvedTc)
const objVal = obj[k]
const valueActualChecker = tc.snapshotType(objVal)
if (!valueActualChecker) {
return null
}
}
return thisTc
},
(obj: Record<string, unknown>) => {
return applySnapshotProcessor(obj, "from")
},
(obj: Record<string, unknown>) => {
return applySnapshotProcessor(obj, "to")
}
)
return thisTc
}, typeInfoGen) as any
}
/**
* A type that represents a plain object.
* Note that the parameter must be a function that returns an object. This is done so objects can support self / cross types.
*
* Example:
* ```ts
* // notice the ({ ... }), not just { ... }
* const pointType = types.object(() => ({
* x: types.number,
* y: types.number
* }))
* ```
*
* @template T Type.
* @param objectFunction Function that generates an object with types.
* @returns
*/
export function typesObject<T>(objectFunction: T): T {
// we can't type this function or else we won't be able to make it work recursively
const typeInfoGen: TypeInfoGen = (t) => new ObjectTypeInfo(t, objectFunction as any)
return typesObjectHelper(objectFunction, false, typeInfoGen) as any
}
/**
* `types.object` type info for an object props.
*/
export interface ObjectTypeInfoProps {
readonly [propName: string]: Readonly<{
type: AnyStandardType
typeInfo: TypeInfo
}>
}
/**
* `types.object` type info.
*/
export class ObjectTypeInfo extends TypeInfo {
// memoize to always return the same object
private _props = lazy(() => {
const objSchema = this._objTypeFn()
const propTypes: O.Writable<ObjectTypeInfoProps> = {}
Object.keys(objSchema).forEach((propName) => {
const type = resolveStandardType(objSchema[propName])
propTypes[propName] = { type, typeInfo: getTypeInfo(type) }
})
return propTypes
})
get props(): ObjectTypeInfoProps {
return this._props()
}
constructor(
thisType: AnyStandardType,
private _objTypeFn: ObjectTypeFunction
) {
super(thisType)
}
}
/**
* A type that represents frozen data.
*
* Example:
* ```ts
* const frozenNumberType = types.frozen(types.number)
* const frozenAnyType = types.frozen(types.unchecked<any>())
* const frozenNumberArrayType = types.frozen(types.array(types.number))
* const frozenUncheckedNumberArrayType = types.frozen(types.unchecked<number[]>())
* ```
*
* @template T Type.
* @param dataType Type of the frozen data.
* @returns
*/
export function typesFrozen<T extends AnyType>(dataType: T): ModelType<Frozen<TypeToData<T>>> {
return typesObjectHelper(
() => ({
data: dataType,
}),
true,
(t) => new FrozenTypeInfo(t, resolveStandardType(dataType))
) as any
}
/**
* `types.frozen` type info.
*/
export class FrozenTypeInfo extends TypeInfo {
get dataTypeInfo(): TypeInfo {
return getTypeInfo(this.dataType)
}
constructor(
thisType: AnyStandardType,
readonly dataType: AnyStandardType
) {
super(thisType)
}
}