UNPKG

immutable-class

Version:

A template for creating immutable classes

430 lines (366 loc) 13.4 kB
/* * Copyright 2015-2019 Imply Data, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import hasOwnProp from 'has-own-prop'; import { generalEqual } from '../equality/equality'; import { NamedArray } from '../named-array/named-array'; function firstUp(name: string): string { return name[0].toUpperCase() + name.slice(1); } function isDefined(v: any, emptyArrayIsOk: boolean | undefined) { return Array.isArray(v) ? v.length || emptyArrayIsOk : v != null; } function noop(v: any) { return v; } const EXPLAIN_UDFCF = 'This might indicate that you are using "useDefineForClassFields" and forgot to use "declare" on an auto-generated getter property.'; export type Validator = (x: any) => void; export interface ImmutableLike { fromJS: (js: any, context?: any) => any; } export type PropertyType = 'date' | 'array'; export const PropertyType = { DATE: 'date' as PropertyType, ARRAY: 'array' as PropertyType, }; export interface Property<T extends Record<string, any> = any> { name: keyof T & string; defaultValue?: any; possibleValues?: readonly any[]; validate?: Validator | Validator[]; type?: PropertyType; immutableClass?: ImmutableLike; immutableClassArray?: ImmutableLike; equal?: (a: any, b: any) => boolean; toJS?: (v: any) => any; // todo.. stricter js type? contextTransform?: (context: Record<string, any>) => Record<string, any>; preserveUndefined?: boolean; emptyArrayIsOk?: boolean; } export interface ClassFnType { PROPERTIES: Property[]; fromJS(properties: any, context?: any): any; new (properties: any): any; } export interface ImmutableInstanceType<ValueType, JSType> { valueOf(): ValueType; toJS(): JSType; toJSON(): JSType; toString(): string; equals(other: ImmutableInstanceType<ValueType, JSType> | undefined): boolean; } export interface BackCompat { condition: (js: any) => boolean; action: (js: any) => void; } export abstract class BaseImmutable<ValueType extends Record<string, any>, JSType> implements ImmutableInstanceType<ValueType, JSType> { // This needs to be defined // abstract static PROPERTIES: Property[]; static jsToValue<T extends Record<string, any> = any>( properties: Property<T>[], js: any, backCompats?: BackCompat[], context?: Record<string, any>, ): any { if (properties == null) { throw new Error(`JS is not defined`); } if (Array.isArray(backCompats)) { let jsCopied = false; for (const backCompat of backCompats) { if (backCompat.condition(js)) { if (!jsCopied) { js = JSON.parse(JSON.stringify(js)); jsCopied = true; } backCompat.action(js); } } } const value: any = {}; for (const property of properties) { const propertyName = property.name; if (typeof propertyName !== 'string') continue; const contextTransform = property.contextTransform || noop; let pv: any = js[propertyName]; if (pv != null) { if (property.type === PropertyType.DATE) { pv = new Date(pv); } else if (property.immutableClass) { pv = property.immutableClass.fromJS(pv, context ? contextTransform(context) : undefined); } else if (property.immutableClassArray) { if (!Array.isArray(pv)) throw new Error(`expected ${propertyName} to be an array`); const propertyImmutableClassArray: any = property.immutableClassArray; pv = pv.map((v: any) => propertyImmutableClassArray.fromJS(v, context ? contextTransform(context) : undefined), ); } } value[propertyName] = pv; } return value; } static finalize(ClassFn: ClassFnType): void { const proto = (ClassFn as any).prototype; ClassFn.PROPERTIES.forEach((property: Property) => { const propertyName = property.name; if (typeof propertyName !== 'string') return; const defaultValue = property.defaultValue; const upped = firstUp(propertyName); const getUpped = 'get' + upped; const changeUpped = 'change' + upped; // These have to be `function` and not `=>` so that they do not bind 'this' proto[getUpped] = proto[getUpped] || function (this: any) { const pv = this[propertyName]; return pv != null ? pv : defaultValue; }; proto[changeUpped] = proto[changeUpped] || function (this: any, newValue: any): any { if (this[propertyName] === newValue) return this; const value = this.valueOf(); value[propertyName] = newValue; return new this.constructor(value); }; }); } static ensure = { number: (n: any): void => { if (isNaN(n) || typeof n !== 'number') throw new Error(`must be a number`); }, positive: (n: any): void => { if (n < 0) throw new Error('must be positive'); }, nonNegative: (n: any): void => { if (n < 0) throw new Error('must be non negative'); }, }; constructor(value: ValueType) { const properties = this.ownProperties(); for (const property of properties) { const propertyName = property.name; if (typeof propertyName !== 'string') continue; const propertyType = hasOwnProp(property, 'isDate') ? PropertyType.DATE : property.type; const pv = value[propertyName]; if (pv == null) { if (propertyType === PropertyType.ARRAY) { (this as any)[propertyName] = []; continue; } if (!hasOwnProp(property, 'defaultValue')) { throw new Error(`${this.constructor.name}.${propertyName} must be defined`); } } else { const possibleValues = property.possibleValues; if (possibleValues && !possibleValues.includes(pv)) { throw new Error( `${ this.constructor.name }.${propertyName} can not have value '${pv}' must be one of [${possibleValues.join( ', ', )}]`, ); } if (property.type === PropertyType.DATE) { if (isNaN(pv)) { throw new Error(`${this.constructor.name}.${propertyName} must be a Date`); } } if (property.type === PropertyType.ARRAY) { if (!Array.isArray(pv)) { throw new Error(`${this.constructor.name}.${propertyName} must be an Array`); } } const validate = property.validate; if (validate) { const validators: Validator[] = Array.isArray(validate) ? validate : [validate]; try { for (const validator of validators) validator(pv); } catch (e) { throw new Error(`${this.constructor.name}.${propertyName} ${(e as Error).message}`); } } } Object.defineProperty(this, propertyName, { value: pv, configurable: true, enumerable: true, writable: false, }); } } public ownProperties(): Property<ValueType>[] { return (this.constructor as any).PROPERTIES; } public findOwnProperty(propName: keyof ValueType & string): Property | undefined { const properties = this.ownProperties(); return NamedArray.findByName(properties, propName); } public hasProperty(propName: keyof ValueType & string): boolean { return this.findOwnProperty(propName) !== null; } public valueOf(): ValueType { const value: any = {}; const properties = this.ownProperties(); for (const property of properties) { const propertyName = property.name; value[propertyName] = (this as any)[propertyName]; } return value; } public toJS(): JSType { const js: any = {}; const properties = this.ownProperties(); for (const property of properties) { const propertyName = property.name; let pv: any = (this as any)[propertyName]; if (isDefined(pv, property.emptyArrayIsOk) || property.preserveUndefined) { if (typeof property.toJS === 'function') { const toJS = property.toJS; pv = property.immutableClassArray ? pv.map(toJS) : toJS(pv); } else if (property.immutableClass) { pv = pv.toJS(); } else if (property.immutableClassArray) { pv = pv.map((v: any) => v.toJS()); } js[propertyName] = pv; } } return js; } public toJSON(): JSType { return this.toJS(); } public toString(): string { const name: any = (this as any).name; const extra = name === 'string' ? `: ${name}` : ''; return `[ImmutableClass${extra}]`; } public getDifference( other: BaseImmutable<ValueType, JSType> | undefined, returnOnFirstDifference = false, ): string[] { if (!other) return ['__no_other__']; if (this === other) return []; if (!(other instanceof this.constructor)) return ['__different_constructors__']; const differences: string[] = []; const properties = this.ownProperties(); for (const property of properties) { const equal = property.equal || generalEqual; if (!equal((this as any)[property.name], (other as any)[property.name])) { const difference = property.name; if (returnOnFirstDifference) return [difference]; differences.push(difference); } } return differences; } public equals(other: BaseImmutable<ValueType, JSType> | undefined): boolean { return this.getDifference(other, true).length === 0; } public equivalent(other: BaseImmutable<ValueType, JSType>): boolean { if (!other) return false; if (this === other) return true; if (!(other instanceof this.constructor)) return false; const properties = this.ownProperties(); for (const property of properties) { const propertyName = property.name; const equal = property.equal || generalEqual; if (!equal(this.get(propertyName), other.get(propertyName))) return false; } return true; } public get<T extends keyof ValueType & string>(propName: T): ValueType[T] { const getter = (this as any)['get' + firstUp(propName)]; if (!getter) { const msg = `No getter was found for "${propName}"`; if (Object.getOwnPropertyDescriptor(this, 'get' + firstUp(propName))) { throw new Error(msg + ' but it is defined as a property. ' + EXPLAIN_UDFCF); } else { throw new Error(msg + '.'); } } return getter.call(this); } public change<T extends keyof ValueType & string>(propName: T, newValue: ValueType[T]): this { const changer = (this as any)['change' + firstUp(propName)]; if (!changer) { const msg = `No changer was found for "${propName}"`; if (Object.getOwnPropertyDescriptor(this, 'change' + firstUp(propName))) { throw new Error(msg + ' but it is defined as a property. ' + EXPLAIN_UDFCF); } else { throw new Error(msg + '.'); } } return changer.call(this, newValue); } public changeMany(properties: Partial<ValueType>): this { if (!properties) throw new TypeError('Invalid properties object'); let o = this; for (const propName in properties) { if (!this.hasProperty(propName)) throw new Error('Unknown property: ' + propName); // Added ! because TypeScript thinks a Partial can have undefined properties // (which they can and it's cool) // https://github.com/Microsoft/TypeScript/issues/13195 o = o.change(propName, properties[propName]!); } return o; } public deepChange(propName: string, newValue: any): this { const bits = propName.split('.'); let lastObject = newValue; let currentObject: any; const getLastObject = () => { let o: any = this; // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < bits.length; i++) { o = o['get' + firstUp(bits[i])](); } return o; }; while (bits.length) { const bit = bits.pop(); currentObject = getLastObject(); if (currentObject.change instanceof Function) { lastObject = currentObject.change(bit, lastObject); } else { const message = "Can't find `change()` method on " + currentObject.constructor.name; throw new Error(message); } } return lastObject; } public deepGet(propName: string): any { let value = this as any; const bits = propName.split('.'); let bit; while ((bit = bits.shift())) { const specializedGetterName = `get${firstUp(bit)}`; const specializedGetter = value[specializedGetterName]; value = specializedGetter ? specializedGetter.call(value) : value.get ? value.get(bit) : value[bit]; } return value; } }