Skip to content

Commit

Permalink
fix(TinyType): Improvements to serialisation/de-serialisation types
Browse files Browse the repository at this point in the history
  • Loading branch information
jan-molak committed Feb 21, 2018
1 parent cf87c85 commit bb27ac0
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 54 deletions.
61 changes: 25 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,56 +199,45 @@ const firstName = new FirstName('Jan'),
FirstName.fromJSON(firstName.toJSON()).equals(firstName) === true
```

Optionally, you could take this further and define the shape of serialised objects
to ensure that `fromJSON` and `toJSON` are compatible:
When working with complex TinyTypes, you can use the (experimental) `Serialised` interface
to reduce the likelihood of your custom `fromJSON` method being incompatible with `toJSON`:

```typescript
import { TinyTypeOf } from 'tiny-types';
import { TinyTypeOf, TinyType, Serialised } from 'tiny-types';

type SerialisedFirstName = string;
class FirstName extends TinyTypeOf<string>() {
static fromJSON = (v: SerialisedFirstName) => new FirstName(v);
toJSON(): SerialisedFirstName {
return super.toJSON() as SerialisedFirstName;
}
class EmployeeId extends TinyTypeOf<number>() {
static fromJSON = (id: number) => new EmployeeId(id);
}

const firstName = new FirstName('Jan'),

FirstName.fromJSON(firstName.toJSON()).equals(firstName) === true
```

This way de-serialising a more complex TinyType becomes trivial:

```typescript
import { JSONObject, TinyType } from 'tiny-types';

interface SerialisedPerson extends JSONObject {
firstName: SerialisedFirstName;
lastName: SerialisedLastName;
age: SerialisedAge;
class DepartmentId extends TinyTypeOf<string>() {
static fromJSON = (id: string) => new DepartmentId(id);
}

class Person extends TinyType {
static fromJSON = (v: SerialisedPerson) => new Person(
FirstName.fromJSON(v.firstName),
LastName.fromJSON(v.lastName),
Age.fromJSON(v.age),
class Allocation extends TinyType {
static fromJSON = (o: Serialised<Allocation>) => new Allocation(
EmployeeId.fromJSON(o.employeeId as number),
DepartmentId.fromJSON(o.departmentId as string),
)

constructor(public readonly firstName: FirstName,
public readonly lastName: LastName,
public readonly age: Age,
) {
constructor(public readonly employeeId: EmployeeId, public readonly departmentId: DepartmentId) {
super();
}

toJSON(): SerialisedPerson {
return super.toJSON() as SerialisedPerson;
}
}
```

This way de-serialising a complex type becomes trivial:

```typescript
const allocation = new Allocation(new EmployeeId(1), new DepartmentId('engineering'));

const deserialised = Allocation.fromJSON({ departmentId: 'engineering', employeeId: 1 });

allocation.equals(deserialised) === true
```

Although `Serialised` is by no means 100% foolproof as it's only limited to checking whether your input JSON has the same fields
as the object you're trying to de-serialise, it can at least help you to avoid errors caused by typos.

## Your feedback matters!

Do you find TinyTypes useful? [Give it a star!](https://github.com/jan-molak/tiny-types) &#9733;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"lib",
"node_modules",
"spec",
"src/types.ts"
"src/types"
],
"extension": [
".ts"
Expand Down
114 changes: 114 additions & 0 deletions spec/serialisation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'mocha';
import { TinyType, TinyTypeOf } from '../src';
import { JSONObject, Serialised } from '../src/types';
import { expect } from './expect';

describe('Serialisation', () => {

describe('of TinyTypes wrapping several primitive values', () => {

class Person extends TinyType {
public role: string = 'dev';

static fromJSON(o: Serialised<Person>): Person {
return new Person(o.firstName as string, o.lastName as string, o.age as number);
}

constructor(public readonly firstName: string, public readonly lastName: string, public readonly age: number) {
super();
}

speak() {
return `Hi, I'm ${this.firstName} ${this.lastName}`;
}
}

it('uses only the significant fields and retains their type', () => {
const p = new Person('John', 'Smith', 42);
const serialised = p.toJSON() as Serialised<Person>;

expect(Object.keys(serialised)).to.include.ordered.members(['age', 'firstName', 'lastName', 'role']);
expect(Object.keys(serialised)).to.not.include.members(['speak', 'toJSON', 'toString']);

expect(serialised.age).to.be.a('number');
expect(serialised.firstName).to.be.a('string');
expect(serialised.lastName).to.be.a('string');
expect(serialised.role).to.be.a('string');
});
});

describe('of nested Tiny Types', () => {
class FirstName extends TinyTypeOf<string>() {
static fromJSON = (v: string) => new FirstName(v);
}
class LastName extends TinyTypeOf<string>() {
static fromJSON = (v: string) => new LastName(v);
}
class Age extends TinyTypeOf<number>() {
static fromJSON = (v: number) => new Age(v);
}

class AnotherPerson extends TinyType {
public role: string = 'dev';

static fromJSON(o: Serialised<AnotherPerson>): AnotherPerson {
return new AnotherPerson(
FirstName.fromJSON(o.firstName as string),
LastName.fromJSON(o.lastName as string),
Age.fromJSON(o.age as number),
);
}

constructor(public readonly firstName: FirstName,
public readonly lastName: LastName,
public readonly age: Age,
) {
super();
}

speak() {
return `Hi, I'm ${this.firstName} ${this.lastName}`;
}
}

it('uses only the significant fields and retains the type of their respective values', () => {
const p = new AnotherPerson(new FirstName('John'), new LastName('Smith'), new Age(42));
const serialised = p.toJSON() as Serialised<AnotherPerson>;

expect(Object.keys(serialised)).to.include.ordered.members(['age', 'firstName', 'lastName', 'role']);
expect(Object.keys(serialised)).to.not.include.members(['speak', 'toJSON', 'toString']);

expect(serialised.age).to.be.a('number');
expect(serialised.firstName).to.be.a('string');
expect(serialised.lastName).to.be.a('string');
expect(serialised.role).to.be.a('string');
});
});

it('works both ways', () => {
class EmployeeId extends TinyTypeOf<number>() {
static fromJSON = (id: number) => new EmployeeId(id);
}

class DepartmentId extends TinyTypeOf<string>() {
static fromJSON = (id: string) => new DepartmentId(id);
}

class Allocation extends TinyType {
static fromJSON = (o: Serialised<Allocation>) => new Allocation(
EmployeeId.fromJSON(o.employeeId as number),
DepartmentId.fromJSON(o.departmentId as string),
)

constructor(public readonly employeeId: EmployeeId, public readonly departmentId: DepartmentId) {
super();
}
}

const allocation = new Allocation(new EmployeeId(1), new DepartmentId('engineering'));

const deserialised = Allocation.fromJSON({ departmentId: 'engineering', employeeId: 1 });

expect(deserialised.equals(allocation)).to.be.true; // tslint:disable-line:no-unused-expression
});
});
15 changes: 8 additions & 7 deletions src/TinyType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JSONValue } from './types';
import { JSONObject, JSONValue, NonNullJSONPrimitive, Serialisable, Serialised } from './types';

/**
* @desc The {@link TinyTypeOf} can be used to define simple
Expand Down Expand Up @@ -41,7 +41,7 @@ export function TinyTypeOf<T>(): { new(_: T): { value: T } & TinyType } {
* }
* }
*/
export abstract class TinyType {
export abstract class TinyType implements Serialisable {

/**
* @desc Compares two tiny types by value
Expand Down Expand Up @@ -131,18 +131,19 @@ export abstract class TinyType {
* person.toJSON() === { firstName: 'John', lastName: 'Smith', age: 42 }
*
* @returns {JSONValue}
*
* @todo should also serialise arrays
*/
// todo: serialise arrays
toJSON(): JSONValue {
toJSON(): JSONObject | NonNullJSONPrimitive {
const isPrimitive = (value: any) => Object(value) !== value;
function toJSON(value: any) {
function toJSON(value: any): JSONObject | NonNullJSONPrimitive {
switch (true) {
case value && !! value.toJSON:
return value.toJSON();
case value && ! isPrimitive(value):
return JSON.stringify(value);
default:
return value;
return value; // todo: could this ever be a null?
}
}

Expand All @@ -155,7 +156,7 @@ export abstract class TinyType {
return fields.reduce((acc, field) => {
acc[field] = toJSON(this[field]);
return acc;
}, {});
}, {}) as Serialised<this>;
}

/**
Expand Down
10 changes: 0 additions & 10 deletions src/types.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/types/constructors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type ConstructorOrAbstract<T = {}> = Function & { prototype: T }; // tslint:disable-line:ban-types
export type ConstructorAbstractOrInstance<T = {}> = T | ConstructorOrAbstract; // tslint:disable-line:ban-types
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './constructors';
export * from './json';
export * from './list';
export * from './serialisation';
8 changes: 8 additions & 0 deletions src/types/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type Null = null;
export type NonNullJSONPrimitive = string | number | boolean;
export type JSONPrimitive = NonNullJSONPrimitive | Null;
export interface JSONObject {
[_: string]: JSONPrimitive | JSONObject | JSONArray;
}
export interface JSONArray extends Array<JSONValue> {} // tslint:disable-line:no-empty-interface
export type JSONValue = JSONPrimitive | JSONObject | JSONArray;
1 change: 1 addition & 0 deletions src/types/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type List<T> = T[];
31 changes: 31 additions & 0 deletions src/types/lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Here be dragons 🔥🐉

// see https://github.com/Microsoft/TypeScript/issues/18133#issuecomment-325910172
export type Bool = 'true' | 'false';

export type Not<X extends Bool> = {
true: 'false',
false: 'true',
}[X];

export type HaveIntersection<S1 extends string, S2 extends string> = (
{ [K in S1]: 'true' } &
{ [key: string]: 'false' }
)[S2];

export type IsNeverWorker<S extends string> = (
{ [K in S]: 'false' } &
{ [key: string]: 'true' }
)[S];

// Worker needed because of https://github.com/Microsoft/TypeScript/issues/18118
export type IsNever<T extends string> = Not<HaveIntersection<IsNeverWorker<T>, 'false'>>;

export type IsFunction<T> = IsNever<keyof T>;

export type NonFunctionProps<T> = {
[K in keyof T]: {
'false': K,
'true': never,
}[IsFunction<T[K]>]
}[keyof T];
11 changes: 11 additions & 0 deletions src/types/serialisation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { JSONValue } from './json';
import { NonFunctionProps } from './lookup';

export interface Serialisable<S extends JSONValue = JSONValue> {
toJSON(): S;
}

/**
* @experimental
*/
export type Serialised<T extends object> = { [P in NonFunctionProps<T>]: JSONValue };

0 comments on commit bb27ac0

Please sign in to comment.