immutable-class
Version:
A template for creating immutable classes
430 lines (366 loc) • 13.4 kB
text/typescript
/*
* 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;
}
}