diff --git a/spec/TinyType.spec.ts b/spec/TinyType.spec.ts index 3459d51a..e9f65088 100644 --- a/spec/TinyType.spec.ts +++ b/spec/TinyType.spec.ts @@ -6,332 +6,287 @@ import { expect } from './expect'; /** @test {TinyType} */ describe('TinyType', () => { - /** @test {TinyType} */ - describe('definition', () => { + describe('wrapping a single value', () => { - /** @test {TinyTypeOf} */ - it('can be a one-liner for TinyTypes representing a single value', () => { - class FirstName extends TinyTypeOf() {} - - const firstName = new FirstName('Jan'); + /** @test {TinyType} */ + describe('definition', () => { - expect(firstName.value).to.equal('Jan'); - expect(firstName).to.be.instanceOf(FirstName); - expect(firstName).to.be.instanceOf(TinyType); - expect(firstName.constructor.name).to.equal('FirstName'); - expect(firstName.toString()).to.equal('FirstName(value=Jan)'); - }); + /** @test {TinyTypeOf} */ + it('can be a one-liner for TinyTypes representing a single value', () => { + class FirstName extends TinyTypeOf() { + } - /** @test {TinyTypeOf} */ - it('prevents null and undefined when the single-line definition style is used', () => { - class FirstName extends TinyTypeOf() {} + const firstName = new FirstName('Jan'); - expect(() => new FirstName(null as any)).to.throw('FirstName should be defined'); - expect(() => new FirstName(undefined as any)).to.throw('FirstName should be defined'); - }); - - /** - * @test {TinyType} - * @test {TinyTypeOf} - */ - it('needs to extend the TinyType for types with more than one value', () => { - class FirstName extends TinyTypeOf() {} - class LastName extends TinyTypeOf() {} + expect(firstName.value).to.equal('Jan'); + expect(firstName).to.be.instanceOf(FirstName); + expect(firstName).to.be.instanceOf(TinyType); + expect(firstName.constructor.name).to.equal('FirstName'); + expect(firstName.toString()).to.equal('FirstName(value=Jan)'); + }); - class FullName extends TinyType { - constructor(public readonly firstName: FirstName, - public readonly lastName: LastName) { - super(); + /** @test {TinyTypeOf} */ + it('prevents null and undefined when the single-line definition style is used', () => { + class FirstName extends TinyTypeOf() { } - } - - const fullName = new FullName(new FirstName('Jan'), new LastName('Molak')); - expect(fullName.firstName.value).to.equal('Jan'); - expect(fullName.lastName.value).to.equal('Molak'); - expect(fullName).to.be.instanceOf(FullName); - expect(fullName).to.be.instanceOf(FullName); - expect(fullName.constructor.name).to.equal('FullName'); - expect(fullName.toString()).to.equal('FullName(firstName=FirstName(value=Jan), lastName=LastName(value=Molak))'); - }); + expect(() => new FirstName(null as any)).to.throw('FirstName should be defined'); + expect(() => new FirstName(undefined as any)).to.throw('FirstName should be defined'); + }); - /** - * @test {TinyType} - * @test {TinyTypeOf} - */ - it('can be mixed and matched', () => { - const now = new Date(Date.UTC(2018, 2, 12, 0, 30, 0)); - - class UserName extends TinyTypeOf() {} - class Timestamp extends TinyTypeOf() { - toString() { - return `Timestamp(value=${this.value.toISOString()})`; + /** + * @test {TinyType} + * @test {TinyTypeOf} + */ + it('needs to extend the TinyType for types with more than one value', () => { + class FirstName extends TinyTypeOf() { } - } - abstract class DomainEvent extends TinyTypeOf() {} + class LastName extends TinyTypeOf() { + } - class AccountCreated extends DomainEvent { - constructor(public readonly username: UserName, timestamp: Timestamp) { - super(timestamp); + class FullName extends TinyType { + constructor(public readonly firstName: FirstName, + public readonly lastName: LastName) { + super(); + } } - } - const e = new AccountCreated(new UserName('jan-molak'), new Timestamp(now)); + const fullName = new FullName(new FirstName('Jan'), new LastName('Molak')); - expect(e.toString()).to.equal( - 'AccountCreated(username=UserName(value=jan-molak), value=Timestamp(value=2018-03-12T00:30:00.000Z))', - ); - }); - }); + expect(fullName.firstName.value).to.equal('Jan'); + expect(fullName.lastName.value).to.equal('Molak'); + expect(fullName).to.be.instanceOf(FullName); + expect(fullName).to.be.instanceOf(FullName); + expect(fullName.constructor.name).to.equal('FullName'); + expect(fullName.toString()).to.equal('FullName(firstName=FirstName(value=Jan), lastName=LastName(value=Molak))'); + }); - /** @test {TinyType#equals} */ - describe('::equals', () => { - class Name extends TinyType { - constructor(public readonly value: string) { - super(); - } - } + /** + * @test {TinyType} + * @test {TinyTypeOf} + */ + it('can be mixed and matched', () => { + const now = new Date(Date.UTC(2018, 2, 12, 0, 30, 0)); - class Role extends TinyType { - constructor(public readonly value: string) { - super(); - } - } + class UserName extends TinyTypeOf() { + } - class Person extends TinyType { - constructor(public readonly name: Name, public readonly role: Role) { super(); } - } + class Timestamp extends TinyTypeOf() { + toString() { + return `Timestamp(value=${this.value.toISOString()})`; + } + } - it('is reflexive', () => { + abstract class DomainEvent extends TinyTypeOf() { + } - const n1 = new Name('Jan'); + class AccountCreated extends DomainEvent { + constructor(public readonly username: UserName, timestamp: Timestamp) { + super(timestamp); + } + } + + const e = new AccountCreated(new UserName('jan-molak'), new Timestamp(now)); - expect(n1.equals(n1)).to.be.true; // tslint:disable-line:no-unused-expression + expect(e.toString()).to.equal( + 'AccountCreated(username=UserName(value=jan-molak), value=Timestamp(value=2018-03-12T00:30:00.000Z))', + ); + }); }); - it('is symmetric', () => { + /** @test {TinyType#toString} */ + describe('::toString', () => { + class Area extends TinyTypeOf() {} - const - n1 = new Name('Jan'), - n2 = new Name('Jan'); + class District extends TinyTypeOf() {} - expect(n1.equals(n2)).to.be.true; // tslint:disable-line:no-unused-expression - expect(n1.equals(n2)).to.equal(n2.equals(n1)); - }); + class Sector extends TinyTypeOf() {} - it('is transitive', () => { + class Unit extends TinyTypeOf() {} - const - n1 = new Name('Jan'), - n2 = new Name('Jan'), - n3 = new Name('Jan'); + class Postcode extends TinyType { + constructor(public readonly area: Area, + public readonly district: District, + public readonly sector: Sector, + public readonly unit: Unit,) { + super(); + } + } - expect(n1.equals(n2)).to.be.true; // tslint:disable-line:no-unused-expression - expect(n2.equals(n3)).to.be.true; // tslint:disable-line:no-unused-expression - expect(n1.equals(n3)).to.be.true; // tslint:disable-line:no-unused-expression - }); + it('mentions the class and its properties', () => { + const area = new Area('GU'); - it('is recursive', () => { + expect(area.toString()).to.equal('Area(value=GU)'); + }); - const - p1 = new Person(new Name('Jan'), new Role('dev')), - p2 = new Person(new Name('Jan'), new Role('dev')), - p3 = new Person(new Name('John'), new Role('dev')); + it('mentions the class and its fields, but not the methods', () => { + class Person extends TinyType { + constructor(public readonly name: string) { + super(); + } - expect(p1.equals(p2)).to.be.true; // tslint:disable-line:no-unused-expression - expect(p1.equals(p3)).to.be.false; // tslint:disable-line:no-unused-expression - }); + rename = (newName: string) => new Person(newName); + } - it('works for private fields', () => { - class PrivatePerson extends TinyType { - constructor(private readonly name: Name, public readonly role: Role) { super(); } - } + const p = new Person('James'); - const - p1 = new PrivatePerson(new Name('Jan'), new Role('dev')), - p2 = new PrivatePerson(new Name('Jan'), new Role('dev')), - p3 = new PrivatePerson(new Name('John'), new Role('dev')); + expect(p.toString()) + .to.equal('Person(name=James)'); + }); - expect(p1.equals(p2)).to.be.true; // tslint:disable-line:no-unused-expression - expect(p1.equals(p3)).to.be.false; // tslint:disable-line:no-unused-expression - }); + it('only cares about the fields, not the methods', () => { + const postcode = new Postcode( + new Area('GU'), + new District(15), + new Sector(9), + new Unit('NZ'), + ); - given( - undefined, - null, - {}, - [], - 1, - '', - false, - ).it('is false for objects of a different type', (another: any) => { - const n = new Name('Jan'); - - expect(n.equals(another)).to.be.false; // tslint:disable-line:no-unused-expression + expect(postcode.toString()) + .to.equal('Postcode(area=Area(value=GU), district=District(value=15), sector=Sector(value=9), unit=Unit(value=NZ))'); + }); }); - }); - /** @test {TinyType#toString} */ - describe('::toString', () => { - class Area extends TinyTypeOf() {} - class District extends TinyTypeOf() {} - class Sector extends TinyTypeOf() {} - class Unit extends TinyTypeOf() {} - - class Postcode extends TinyType { - constructor(public readonly area: Area, - public readonly district: District, - public readonly sector: Sector, - public readonly unit: Unit, - ) { - super(); - } - } + /** @test {TinyType#toJSON} */ + describe('serialisation', () => { + + class FirstName extends TinyTypeOf() {} - it('mentions the class and its properties', () => { - const area = new Area('GU'); + class LastName extends TinyTypeOf() {} - expect(area.toString()).to.equal('Area(value=GU)'); - }); + class Age extends TinyTypeOf() {} - it('mentions the class and its fields, but not the methods', () => { class Person extends TinyType { - constructor(public readonly name: string) { + constructor(public readonly firstName: FirstName, + public readonly lastName: LastName, + public readonly age: Age, + ) { super(); } - rename = (newName: string) => new Person(newName); } - const p = new Person('James'); + class People extends TinyTypeOf() { + } - expect(p.toString()) - .to.equal('Person(name=James)'); - }); + describe('::toJSON', () => { - it('only cares about the fields, not the methods', () => { - const postcode = new Postcode( - new Area('GU'), - new District(15), - new Sector(9), - new Unit('NZ'), - ); + given( + new FirstName('Bruce'), + new Age(55), + ).it('should serialise a single-value TinyType to just its value', input => { + expect(input.toJSON()).to.equal(input.value); + }); - expect(postcode.toString()) - .to.equal('Postcode(area=Area(value=GU), district=District(value=15), sector=Sector(value=9), unit=Unit(value=NZ))'); - }); - }); + it('should serialise a complex TinyType recursively', () => { - /** @test {TinyType#toJSON} */ - describe('serialisation', () => { - - class FirstName extends TinyTypeOf() {} - class LastName extends TinyTypeOf() {} - class Age extends TinyTypeOf() {} - class Person extends TinyType { - constructor( - public readonly firstName: FirstName, - public readonly lastName: LastName, - public readonly age: Age, - ) { - super(); - } - } + const person = new Person(new FirstName('Bruce'), new LastName('Smith'), new Age(55)); - describe('::toJSON', () => { + expect(person.toJSON()).to.deep.equal({ + firstName: 'Bruce', + lastName: 'Smith', + age: 55, + }); + }); - given( - new FirstName('Bruce'), - new Age(55), - ). - it('should serialise a single-value TinyType to just its value', input => { - expect(input.toJSON()).to.equal(input.value); - }); + it(`should serialise an array recursively`, () => { + const people = new People([ + new Person(new FirstName('Alice'), new LastName('Jones'), new Age(62)), + new Person(new FirstName('Bruce'), new LastName('Smith'), new Age(55)), + ]); + + expect(people.toJSON()).to.deep.equal([{ + firstName: 'Alice', + lastName: 'Jones', + age: 62, + }, { + firstName: 'Bruce', + lastName: 'Smith', + age: 55, + }]); + }); - it('should serialise a complex TinyType recursively', () => { + it(`should JSON.stringify any object that can't be represented in a more sensible way`, () => { + class TT extends TinyTypeOf() { + } - const person = new Person(new FirstName('Bruce'), new LastName('Smith'), new Age(55)); + const tt = new TT(new Object({key: 'value'})); - expect(person.toJSON()).to.deep.equal({ - firstName: 'Bruce', - lastName: 'Smith', - age: 55, + expect(tt.toJSON()).to.equal('{"key":"value"}'); }); }); + }); - it(`should JSON.stringify any object that can't be represented in a more sensible way`, () => { - class TT extends TinyTypeOf() {} + /** @test {TinyType} */ + describe('de-serialisation', () => { - const tt = new TT(new Object({ key: 'value' })); + type SerialisedFirstName = string & JSONPrimitive; - expect(tt.toJSON()).to.equal('{"key":"value"}'); - }); - }); - }); + class FirstName extends TinyTypeOf() { + static fromJSON = (v: SerialisedFirstName) => new FirstName(v); - /** @test {TinyType} */ - describe('de-serialisation', () => { - - type SerialisedFirstName = string & JSONPrimitive; - class FirstName extends TinyTypeOf() { - static fromJSON = (v: SerialisedFirstName) => new FirstName(v); - toJSON(): SerialisedFirstName { - return super.toJSON() as SerialisedFirstName; + toJSON(): SerialisedFirstName { + return super.toJSON() as SerialisedFirstName; + } } - } - type SerialisedLastName = string & JSONPrimitive; - class LastName extends TinyTypeOf() { - static fromJSON = (v: SerialisedLastName) => new LastName(v); - toJSON(): SerialisedLastName { - return super.toJSON() as SerialisedLastName; + type SerialisedLastName = string & JSONPrimitive; + + class LastName extends TinyTypeOf() { + static fromJSON = (v: SerialisedLastName) => new LastName(v); + + toJSON(): SerialisedLastName { + return super.toJSON() as SerialisedLastName; + } } - } - type SerialisedAge = number & JSONPrimitive; - class Age extends TinyTypeOf() { - static fromJSON = (v: SerialisedAge) => new Age(v); - toJSON(): SerialisedAge { - return super.toJSON() as SerialisedAge; + type SerialisedAge = number & JSONPrimitive; + + class Age extends TinyTypeOf() { + static fromJSON = (v: SerialisedAge) => new Age(v); + + toJSON(): SerialisedAge { + return super.toJSON() as SerialisedAge; + } } - } - - interface SerialisedPerson extends JSONObject { - firstName: SerialisedFirstName; - lastName: SerialisedLastName; - age: SerialisedAge; - } - class Person extends TinyType { - static fromJSON = (v: SerialisedPerson) => new Person( - FirstName.fromJSON(v.firstName), - LastName.fromJSON(v.lastName), - Age.fromJSON(v.age), - ) - - constructor(public readonly firstName: FirstName, - public readonly lastName: LastName, - public readonly age: Age, - ) { - super(); + + interface SerialisedPerson extends JSONObject { + firstName: SerialisedFirstName; + lastName: SerialisedLastName; + age: SerialisedAge; } - toJSON(): SerialisedPerson { - return super.toJSON() as SerialisedPerson; + class Person extends TinyType { + static fromJSON = (v: SerialisedPerson) => new Person( + FirstName.fromJSON(v.firstName), + LastName.fromJSON(v.lastName), + Age.fromJSON(v.age), + ) + + constructor(public readonly firstName: FirstName, + public readonly lastName: LastName, + public readonly age: Age,) { + super(); + } + + toJSON(): SerialisedPerson { + return super.toJSON() as SerialisedPerson; + } } - } - it('custom fromJSON can de-serialise a serialised single-value TinyType', () => { - const firstName = new FirstName('Jan'); + it('custom fromJSON can de-serialise a serialised single-value TinyType', () => { + const firstName = new FirstName('Jan'); - // tslint:disable-next-line:no-unused-expression - expect(FirstName.fromJSON(firstName.toJSON()).equals(firstName)).to.be.true; - }); + // tslint:disable-next-line:no-unused-expression + expect(FirstName.fromJSON(firstName.toJSON()).equals(firstName)).to.be.true; + }); - it('custom fromJSON can recursively de-serialise a serialised complex TinyType', () => { - const person = new Person(new FirstName('Bruce'), new LastName('Smith'), new Age(55)); + it('custom fromJSON can recursively de-serialise a serialised complex TinyType', () => { + const person = new Person(new FirstName('Bruce'), new LastName('Smith'), new Age(55)); - // tslint:disable-next-line:no-unused-expression - expect(Person.fromJSON(person.toJSON()).equals(person)).to.be.true; + // tslint:disable-next-line:no-unused-expression + expect(Person.fromJSON(person.toJSON()).equals(person)).to.be.true; + }); }); }); }); diff --git a/spec/objects/equal.spec.ts b/spec/objects/equal.spec.ts new file mode 100644 index 00000000..80ba6091 --- /dev/null +++ b/spec/objects/equal.spec.ts @@ -0,0 +1,136 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { TinyType, TinyTypeOf } from '../../src'; +import { equal } from '../../src/objects'; +import { expect } from '../expect'; + +/** @test {equal} */ +describe('equal', () => { + describe('when used with primitives', () => { + + given(undefined, null, false, 'string', 42). + it('is reflexive', (primitive: any) => { + expect(equal(primitive, primitive)).to.be.true; // tslint:disable-line:no-unused-expression + }); + + given<{ v1: any, v2: any }>( + { v1: false, v2: false }, + { v1: false, v2: Boolean(false) }, + { v1: 'string', v2: 'string' }, + { v1: 'string', v2: String('string') }, + { v1: 42, v2: 42 }, + { v1: 42, v2: Number(42) }, + { v1: 42, v2: 42.0 }, + { v1: 42, v2: Number(42.0) }, + ). + it('is symmetric', ({ v1, v2 }) => { + expect(equal(v1, v2)).to.be.true; // tslint:disable-line:no-unused-expression + expect(equal(v1, v2)).to.equal(equal(v2, v1)); + }); + + given<{ v1: any, v2: any, v3: any }>( + { v1: false, v2: false, v3: false }, + { v1: 'string', v2: 'string', v3: 'string' }, + { v1: 42, v2: 42, v3: 42 }, + ). + it('is transitive', ({ v1, v2, v3 }) => { + expect(equal(v1, v2)).to.be.true; // tslint:disable-line:no-unused-expression + expect(equal(v2, v3)).to.be.true; // tslint:disable-line:no-unused-expression + expect(equal(v3, v1)).to.be.true; // tslint:disable-line:no-unused-expression + }); + + given<{ v1: any, v2: any }>( + { v1: false, v2: true }, + { v1: 'apple', v2: 'orange' }, + { v1: 42, v2: 24 }, + { v1: false, v2: 'elephant' }, + { v1: null, v2: undefined }, + ). + it('returns false when subjects are not equal', ({ v1, v2 }) => { + expect(equal(v1, v2)).to.be.false; // tslint:disable-line:no-unused-expression + }); + }); + + /** @test {TinyType#equals} */ + describe('when used with TinyTypes', () => { + + class Name extends TinyTypeOf() {} + class Age extends TinyTypeOf() {} + + class Person extends TinyType { + constructor(public readonly name: Name, public readonly role: Age) { + super(); + } + } + + // Please note that there's not much point in wrapping an array into a tiny type, as it doesn't provide + // methods you'd expect from a list, such as map, reduce, forEach, etc. + // What People is here to demonstrate is that `equals` works equally well (pun intended) + // with tiny types with a member field of type Array. + class People extends TinyTypeOf() {} + + const + Alice = new Name('Alice'), + Bob = new Name('Bob'), + MsAlice = new Person(Alice, new Age(28)), + MrBob = new Person(Alice, new Age(38)), + Team = new People([MsAlice, MrBob]), + Team2 = new People([MsAlice]); + + given(Alice, MsAlice, Team). + it('is reflexive', (value: TinyType) => { + expect(equal(value, value)).to.be.true; // tslint:disable-line:no-unused-expression + }); + + given<{ v1: TinyType, v2: TinyType }>( + { v1: new Name('Alice'), v2: new Name('Alice') }, + { v1: new Age(28), v2: new Age(28) }, + { v1: Team, v2: Team }, + { v1: new Person(Alice, new Age(28)), v2: new Person(Alice, new Age(28)) }, + ). + it('is symmetric', ({ v1, v2 }) => { + expect(equal(v1, v2)).to.be.true; // tslint:disable-line:no-unused-expression + expect(equal(v1, v2)).to.equal(equal(v2, v1)); + }); + + given<{ v1: TinyType, v2: TinyType, v3: TinyType }>( + { v1: new Name('Alice'), v2: new Name('Alice'), v3: new Name('Alice') }, + { v1: new Age(28), v2: new Age(28), v3: new Age(28) }, + { v1: new Person(Alice, new Age(28)), v2: new Person(Alice, new Age(28)), v3: new Person(Alice, new Age(28)) }, + ). + it('is transitive', ({ v1, v2, v3 }) => { + expect(equal(v1, v2)).to.be.true; // tslint:disable-line:no-unused-expression + expect(equal(v2, v3)).to.be.true; // tslint:disable-line:no-unused-expression + expect(equal(v3, v1)).to.be.true; // tslint:disable-line:no-unused-expression + }); + + given<{ v1: any, v2: any }>( + { v1: Alice, v2: null }, + { v1: Alice, v2: Bob }, + { v1: Bob, v2: 'cat' }, + { v1: Alice, v2: MsAlice }, + { v1: MsAlice, v2: MrBob }, + { v1: MrBob, v2: Team }, + { v1: Team, v2: Team2 }, + ). + it('returns false when subjects are not equal', ({ v1, v2 }) => { + expect(equal(v1, v2)).to.be.false; // tslint:disable-line:no-unused-expression + }); + + it('compares public and private member fields', () => { + class PrivatePerson extends TinyType { + constructor(private readonly name: Name, private readonly age: Age) { + super(); + } + } + + const + PrivateAlice = new PrivatePerson(new Name('Alice'), new Age(28)), + PrivateRyan = new PrivatePerson(new Name('Ryan'), new Age(28)); + + expect(PrivateAlice.equals(PrivateAlice)).to.be.true; // tslint:disable-line:no-unused-expression + expect(PrivateAlice.equals(PrivateRyan)).to.be.false; // tslint:disable-line:no-unused-expression + }); + }); +}); diff --git a/spec/objects/significantFields.spec.ts b/spec/objects/significantFields.spec.ts new file mode 100644 index 00000000..ab4ca58e --- /dev/null +++ b/spec/objects/significantFields.spec.ts @@ -0,0 +1,37 @@ +import 'mocha'; +import { significantFieldsOf } from '../../src/objects'; +import { expect } from '../expect'; + +/** @test {significantFields} */ +describe('significantFields', () => { + it('returns the names of private and public member fields of an instantiated object', () => { + class Person { + private age: number = 42; + constructor(public readonly firstName: string, private readonly lastName: string) {} + toString() { + return `${this.firstName} ${this.lastName} ${this.age}`; + } + } + + const p = new Person('John', 'Smith'); + + expect(significantFieldsOf(p)).contain.members(['firstName', 'lastName', 'age']); + }); + + it(`returns the names of object's fields`, () => { + const p = { + firstName: 'John', + lastName: 'Smith', + age: 42, + toString: () => `some string`, + }; + + expect(significantFieldsOf(p)).contain.members(['firstName', 'lastName', 'age']); + }); + + it(`returns the keys of an array`, () => { + const list = [ 'a', 'b', 'c' ]; + + expect(significantFieldsOf(list)).contain.members([ '0', '1', '2', 'length' ]); + }); +}); diff --git a/src/TinyType.ts b/src/TinyType.ts index 467ff4b1..c0aabb54 100644 --- a/src/TinyType.ts +++ b/src/TinyType.ts @@ -1,6 +1,7 @@ import { check } from './check'; +import { equal, significantFieldsOf } from './objects'; import { isDefined } from './predicates'; -import { JSONObject, JSONPrimitive, JSONValue, NonNullJSONPrimitive, Serialisable, Serialised } from './types'; +import { JSONObject, JSONValue, NonNullJSONPrimitive, Serialisable, Serialised } from './types'; /** * @desc The {@link TinyTypeOf} can be used to define simple @@ -48,6 +49,8 @@ export function TinyTypeOf(): { new(_: T): { value: T } & TinyType } { */ export abstract class TinyType implements Serialisable { + // todo: can I make all fields `readonly` without making it a dictionary? + /** * @desc Compares two tiny types by value * @@ -81,26 +84,11 @@ export abstract class TinyType implements Serialisable { * @returns {boolean} */ equals(another: TinyType) { - if (another === this) { - return true; - } - - if (! (another instanceof this.constructor)) { - return false; - } - - return this.significantFields().reduce((previousFieldsAreEqual: boolean, field: string) => { - - const currentFieldIsEqual = (this[field].equals - ? this[field].equals(another[field]) - : this[field] === another[field]); - - return previousFieldsAreEqual && currentFieldIsEqual; - }, true); + return equal(this, another); } toString() { - const fields = this.significantFields().reduce((acc: string[], field: string) => { + const fields = significantFieldsOf(this).reduce((acc: string[], field: string) => { return acc.concat(`${field}=${this[field]}`); }, []); @@ -136,8 +124,6 @@ export abstract class TinyType implements Serialisable { * person.toJSON() === { firstName: 'John', lastName: 'Smith', age: 42 } * * @returns {JSONValue} - * - * @todo should also serialise arrays */ toJSON(): JSONObject | NonNullJSONPrimitive { const isPrimitive = (value: any) => Object(value) !== value; @@ -145,6 +131,8 @@ export abstract class TinyType implements Serialisable { switch (true) { case value && !! value.toJSON: return value.toJSON(); + case value && Array.isArray(value): + return value.map(v => toJSON(v)); case value && ! isPrimitive(value): return JSON.stringify(value); default: @@ -152,7 +140,7 @@ export abstract class TinyType implements Serialisable { } } - const fields = this.significantFields(); + const fields = significantFieldsOf(this); if (fields.length === 1) { return toJSON(this[fields[0]]); @@ -163,14 +151,4 @@ export abstract class TinyType implements Serialisable { return acc; }, {}) as Serialised; } - - /** - * @access private - * @returns {string[]} names of significant fields that determine the identity of the object - */ - private significantFields(): string[] { - return Object.getOwnPropertyNames(this) - .filter(field => typeof this[field] !== 'function') - .sort(); - } } diff --git a/src/objects/equal.ts b/src/objects/equal.ts new file mode 100644 index 00000000..a3491753 --- /dev/null +++ b/src/objects/equal.ts @@ -0,0 +1,47 @@ +import { significantFieldsOf } from './significantFields'; + +/** + * @access private + */ +export function equal(v1: any, v2: any): boolean { + switch (true) { + case ! sameType(v1, v2): + return false; + case both(arePrimitives, v1, v2): + return checkIdentityOf(v1, v2); + case both(areObjects, v1, v2) && sameClass(v1, v2): + return checkSignificantFieldsOf(v1, v2); + } + + return false; +} + +const areObjects = (_: any) => new Object(_) === _; +const arePrimitives = (_: any) => ! areObjects(_); // arrays are objects + +function both(condition: (_: any) => boolean, v1: any, v2: any): boolean { + return condition(v1) && condition(v2); +} + +const sameType = (v1: any, v2: any) => typeof v1 === typeof v2; +const sameClass = (v1: any, v2: any) => (v1.constructor && v2 instanceof v1.constructor) || (v2.constructor && v1 instanceof v2.constructor); +const sameLength = (v1: { length: number }, v2: { length: number }) => v1.length === v2.length; + +function checkIdentityOf(v1: any, v2: any) { + return v1 === v2; +} + +function checkSignificantFieldsOf(o1: object, o2: object) { + const + fieldsOfObject1 = significantFieldsOf(o1), + fieldsOfObject2 = significantFieldsOf(o2); + + if (! sameLength(fieldsOfObject1, fieldsOfObject2)) { + return false; + } + + return fieldsOfObject1.reduce((previousFieldsAreEqual: boolean, field: string) => { + const currentFieldIsEqual = !! o1[field] && !! o2[field] && equal(o1[field], o2[field]); + return previousFieldsAreEqual && currentFieldIsEqual; + }, true); +} diff --git a/src/objects/index.ts b/src/objects/index.ts new file mode 100644 index 00000000..5fb238f8 --- /dev/null +++ b/src/objects/index.ts @@ -0,0 +1,2 @@ +export * from './equal'; +export * from './significantFields'; diff --git a/src/objects/significantFields.ts b/src/objects/significantFields.ts new file mode 100644 index 00000000..ccd91635 --- /dev/null +++ b/src/objects/significantFields.ts @@ -0,0 +1,11 @@ +/** + * @access private + * + * @param {object|Array} o + * @returns {string[]} + */ +export function significantFieldsOf(o: { [_: string]: any }) { + return Object.getOwnPropertyNames(o) + .filter(field => typeof o[field] !== 'function') + .sort(); +}