spec/TinyType.spec.ts
import 'mocha';
import { given } from 'mocha-testdata';
import { JSONObject, JSONPrimitive, TinyType, TinyTypeOf } from '../src';
import { expect } from './expect';
/** @test {TinyType} */
describe('TinyType', () => {
describe('wrapping a single value', () => {
/** @test {TinyType} */
describe('definition', () => {
/** @test {TinyTypeOf} */
it('can be a one-liner for TinyTypes representing a single value', () => {
class FirstName extends TinyTypeOf<string>() {
}
const firstName = new FirstName('Jan');
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('prevents null and undefined when the single-line definition style is used', () => {
class FirstName extends TinyTypeOf<string>() {
}
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<string>() {
}
class LastName extends TinyTypeOf<string>() {
}
class FullName extends TinyType {
constructor(
public readonly firstName: FirstName,
public readonly lastName: LastName,
) {
super();
}
}
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))');
});
/**
* @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<string>() {
}
class Timestamp extends TinyTypeOf<Date>() {
toString() {
return `Timestamp(value=${ this.value.toISOString() })`;
}
}
abstract class DomainEvent extends TinyTypeOf<Timestamp>() {
}
class AccountCreated extends DomainEvent {
constructor(public readonly username: UserName, timestamp: Timestamp) {
super(timestamp);
}
}
const event = new AccountCreated(new UserName('jan-molak'), new Timestamp(now));
expect(event.toString()).to.equal(
'AccountCreated(username=UserName(value=jan-molak), value=Timestamp(value=2018-03-12T00:30:00.000Z))',
);
});
});
/** @test {TinyType#toString} */
describe('::toString', () => {
class Area extends TinyTypeOf<string>() {
}
class District extends TinyTypeOf<number>() {
}
class Sector extends TinyTypeOf<number>() {
}
class Unit extends TinyTypeOf<string>() {
}
class Postcode extends TinyType {
constructor(public readonly area: Area,
public readonly district: District,
public readonly sector: Sector,
public readonly unit: Unit,
) {
super();
}
}
it('mentions the class and its properties', () => {
const area = new Area('GU');
expect(area.toString()).to.equal('Area(value=GU)');
});
it('mentions the class and its fields, but not the methods', () => {
class Person extends TinyType {
constructor(public readonly name: string) {
super();
}
rename = (newName: string) => new Person(newName);
}
const p = new Person('James');
expect(p.toString())
.to.equal('Person(name=James)');
});
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'),
);
expect(postcode.toString())
.to.equal('Postcode(area=Area(value=GU), district=District(value=15), sector=Sector(value=9), unit=Unit(value=NZ))');
});
it('prints the array-type properties', () => {
class Name extends TinyTypeOf<string>() {
}
class Names extends TinyTypeOf<Name[]>() {
}
const names = new Names([new Name('Alice'), new Name('Bob')]);
expect(names.toString())
.to.equal('Names(value=Array(Name(value=Alice), Name(value=Bob)))');
});
it('prints the object-type properties', () => {
class Dictionary extends TinyTypeOf<{ [key: string]: string }>() {
}
const dictionary = new Dictionary({ greeting: 'Hello', subject: 'World' });
expect(dictionary.toString())
.to.equal('Dictionary(value=Object(greeting=Hello, subject=World))');
});
});
/** @test {TinyType#toJSON} */
describe('serialisation', () => {
class FirstName extends TinyTypeOf<string>() {
}
class LastName extends TinyTypeOf<string>() {
}
class Age extends TinyTypeOf<number>() {
}
class Person extends TinyType {
constructor(public readonly firstName: FirstName,
public readonly lastName: LastName,
public readonly age: Age,
) {
super();
}
}
class People extends TinyTypeOf<Person[]>() {
}
class FirstNames extends TinyTypeOf<Array<FirstName | undefined>>() {
}
describe('::toJSON', () => {
given<TinyType & { value: any }>(
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 a complex TinyType recursively', () => {
const person = new Person(new FirstName('Bruce'), new LastName('Smith'), new Age(55));
expect(person.toJSON()).to.deep.equal({
firstName: 'Bruce',
lastName: 'Smith',
age: 55,
});
});
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 undefined array values as null`, () => {
const firstNames = new FirstNames([
new FirstName('Alice'),
undefined,
new FirstName('Cecil'),
]);
expect(firstNames.toJSON()).to.deep.equal([
'Alice',
null,
'Cecil',
]);
});
it(`should serialise a plain-old JavaScript object`, () => {
class Parameters extends TinyTypeOf<{ [parameter: string]: string }>() {
}
const parameters = new Parameters({
env: 'prod',
});
expect(parameters.toJSON()).to.deep.equal({
env: 'prod',
});
});
it(`should serialise Map as object`, () => {
class Notes extends TinyTypeOf<Map<string, any>>() {
}
const parameters = new Notes(new Map(Object.entries({
stringEntry: 'prod',
numberEntry: 42,
objectEntry: { key: 'value' },
})));
expect(parameters.toJSON()).to.deep.equal({
stringEntry: 'prod',
numberEntry: 42,
objectEntry: { key: 'value' },
});
});
it(`should serialise a Set as Array`, () => {
class Notes extends TinyTypeOf<Set<string>>() {
}
const parameters = new Notes(new Set(['apples', 'bananas', 'cucumbers']));
expect(parameters.toJSON()).to.deep.equal(['apples', 'bananas', 'cucumbers']);
});
it(`should serialise an Error as its stack trace`, () => {
class CustomError extends TinyTypeOf<Error>() {
}
const error = thrown(new Error('example error'))
const customError = new CustomError(error);
expect(customError.toJSON()).to.deep.equal({
message: 'example error',
stack: error.stack,
});
});
it('should serialise a plain-old JavaScript object with nested complex types recursively', () => {
interface NotesType {
authCredentials: {
username: string;
password: string;
},
names: Set<FirstName>;
age: Map<FirstName, Age>;
}
class Notes extends TinyTypeOf<NotesType>() {
}
const
alice = new FirstName('Alice'),
bob = new FirstName('Bob'),
cindy = new FirstName('Cindy');
const names = new Set<FirstName>([ alice, bob, cindy ]);
const age = new Map<FirstName, Age>()
.set(alice, new Age(23))
.set(bob, new Age(42))
.set(cindy, new Age(67));
const notes = new Notes({
authCredentials: {
username: 'Alice',
password: 'P@ssw0rd!',
},
names,
age
});
expect(notes.toJSON()).to.deep.equal({
authCredentials: {
username: 'Alice',
password: 'P@ssw0rd!',
},
names: [ 'Alice', 'Bob', 'Cindy' ],
age: {
Alice: 23,
Bob: 42,
Cindy: 67,
}
});
});
it('should serialise null and undefined', () => {
interface NotesType {
nullValue: any;
undefinedValue: any;
}
class Notes extends TinyTypeOf<NotesType>() {
}
const notes = new Notes({
nullValue: null,
undefinedValue: undefined,
});
expect(notes.toJSON()).to.deep.equal({
nullValue: null,
undefinedValue: undefined,
});
});
it(`should JSON.stringify any object that can't be represented in a more sensible way`, () => {
class TT extends TinyTypeOf<number>() {
}
const tt = new TT(Number.NaN);
expect(tt.toJSON()).to.equal('null');
});
});
});
/** @test {TinyType} */
describe('de-serialisation', () => {
type SerialisedFirstName = string & JSONPrimitive;
class FirstName extends TinyTypeOf<string>() {
static fromJSON = (v: SerialisedFirstName) => new FirstName(v);
toJSON(): SerialisedFirstName {
return super.toJSON() as SerialisedFirstName;
}
}
type SerialisedLastName = string & JSONPrimitive;
class LastName extends TinyTypeOf<string>() {
static fromJSON = (v: SerialisedLastName) => new LastName(v);
toJSON(): SerialisedLastName {
return super.toJSON() as SerialisedLastName;
}
}
type SerialisedAge = number & JSONPrimitive;
class Age extends TinyTypeOf<number>() {
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();
}
toJSON(): SerialisedPerson {
return super.toJSON() as SerialisedPerson;
}
}
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;
});
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;
});
});
});
});
function thrown<T>(throwable: T): T {
try {
throw throwable;
} catch (error) {
return error as T;
}
}