From 94915c3f742830ad4c72bc40f8d48babdcf156de Mon Sep 17 00:00:00 2001 From: jan-molak Date: Mon, 19 Feb 2018 20:23:44 +0000 Subject: [PATCH] feat(predicates): Predicates help to ensure strong domain models that can guarantee their correctnes --- spec/TinyType.spec.ts | 1 + spec/chec.spec.ts | 15 ++ spec/json.spec.ts | 4 + spec/match.spec.ts | 3 +- spec/pattern-matching/IdentityMatcher.spec.ts | 78 +++--- spec/pattern-matching/ObjectMatcher.spec.ts | 228 +++++++++--------- spec/pattern-matching/StringMatcher.spec.ts | 22 +- .../rules/MatchesAnything.spec.ts | 19 +- spec/predicates/and.spec.ts | 43 ++++ spec/predicates/hasLengthOf.spec.ts | 66 +++++ spec/predicates/isArray.spec.ts | 34 +++ spec/predicates/isDefined.spec.ts | 32 +++ spec/predicates/isEqualTo.spec.ts | 77 ++++++ spec/predicates/isGreaterThan.spec.ts | 38 +++ .../predicates/isGreaterThanOrEqualTo.spec.ts | 40 +++ spec/predicates/isInRange.spec.ts | 47 ++++ spec/predicates/isInteger.spec.ts | 36 +++ spec/predicates/isLessThan.spec.ts | 36 +++ spec/predicates/isLessThanOrEqual.spec.ts | 39 +++ spec/predicates/or.spec.ts | 51 ++++ src/TinyType.ts | 2 + src/check.ts | 46 ++++ src/index.ts | 2 + src/match.ts | 6 + src/pattern-matching/IdentityMatcher.ts | 3 + src/pattern-matching/ObjectMatcher.ts | 3 + src/pattern-matching/PatternMatcher.ts | 3 + src/pattern-matching/StringMatcher.ts | 3 + src/pattern-matching/rules/MatcherRule.ts | 3 + src/pattern-matching/rules/MatchesAnything.ts | 3 + .../rules/MatchesEqualTinyType.ts | 3 + .../rules/MatchesIdentical.ts | 3 + .../MatchesObjectsWithCommonPrototype.ts | 3 + src/pattern-matching/rules/MatchesRegExp.ts | 3 + src/predicates/Predicate.ts | 46 ++++ src/predicates/and.ts | 46 ++++ src/predicates/hasLengthOf.ts | 38 +++ src/predicates/index.ts | 12 + src/predicates/isArray.ts | 22 ++ src/predicates/isDefined.ts | 21 ++ src/predicates/isEqualTo.ts | 43 ++++ src/predicates/isGreaterThan.ts | 24 ++ src/predicates/isGreaterThanOrEqualTo.ts | 23 ++ src/predicates/isInRange.ts | 25 ++ src/predicates/isInteger.ts | 23 ++ src/predicates/isLessThan.ts | 24 ++ src/predicates/isLessThanOrEqualTo.ts | 23 ++ src/predicates/or.ts | 52 ++++ 48 files changed, 1246 insertions(+), 171 deletions(-) create mode 100644 spec/chec.spec.ts create mode 100644 spec/predicates/and.spec.ts create mode 100644 spec/predicates/hasLengthOf.spec.ts create mode 100644 spec/predicates/isArray.spec.ts create mode 100644 spec/predicates/isDefined.spec.ts create mode 100644 spec/predicates/isEqualTo.spec.ts create mode 100644 spec/predicates/isGreaterThan.spec.ts create mode 100644 spec/predicates/isGreaterThanOrEqualTo.spec.ts create mode 100644 spec/predicates/isInRange.spec.ts create mode 100644 spec/predicates/isInteger.spec.ts create mode 100644 spec/predicates/isLessThan.spec.ts create mode 100644 spec/predicates/isLessThanOrEqual.spec.ts create mode 100644 spec/predicates/or.spec.ts create mode 100644 src/check.ts create mode 100644 src/predicates/Predicate.ts create mode 100644 src/predicates/and.ts create mode 100644 src/predicates/hasLengthOf.ts create mode 100644 src/predicates/index.ts create mode 100644 src/predicates/isArray.ts create mode 100644 src/predicates/isDefined.ts create mode 100644 src/predicates/isEqualTo.ts create mode 100644 src/predicates/isGreaterThan.ts create mode 100644 src/predicates/isGreaterThanOrEqualTo.ts create mode 100644 src/predicates/isInRange.ts create mode 100644 src/predicates/isInteger.ts create mode 100644 src/predicates/isLessThan.ts create mode 100644 src/predicates/isLessThanOrEqualTo.ts create mode 100644 src/predicates/or.ts diff --git a/spec/TinyType.spec.ts b/spec/TinyType.spec.ts index 5cd6b317..8c8fe229 100644 --- a/spec/TinyType.spec.ts +++ b/spec/TinyType.spec.ts @@ -6,6 +6,7 @@ import { expect } from './expect'; /** @test {TinyType} */ describe('TinyType', () => { + /** @test {TinyType} */ describe('definition', () => { /** @test {TinyTypeOf} */ diff --git a/spec/chec.spec.ts b/spec/chec.spec.ts new file mode 100644 index 00000000..a47e6366 --- /dev/null +++ b/spec/chec.spec.ts @@ -0,0 +1,15 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, hasLengthOf, isArray, isDefined, isGreaterThan, isInteger, TinyType } from '../src'; +import { expect } from './expect'; + +/** @test {check} */ +describe('::check', () => { + + it(`advises the developer if they've forgotten to specify the checks`, () => { + const value = 2; + expect(() => check('SomeProperty', value)) + .to.throw(`Looks like you haven't specified any predicates to check the value of SomeProperty against?`); + }); +}); diff --git a/spec/json.spec.ts b/spec/json.spec.ts index 2c0113c5..23d49b6b 100644 --- a/spec/json.spec.ts +++ b/spec/json.spec.ts @@ -10,12 +10,14 @@ describe('JSON', () => { Some_Object = {k1: Some_String, k2: Some_Number}, Some_Array = [Some_String, Some_Number, Some_Boolean, Some_Object]; + /** @test {JSONArray} */ describe('JSONArray', () => { it(`describes an array that's a valid JSON`, () => { const array: JSONArray = Some_Array; }); }); + /** @test {JSONObject} */ describe('JSONObject', () => { it(`describes a JavaScript object serialised to JSON`, () => { const object: JSONObject = { @@ -28,6 +30,7 @@ describe('JSON', () => { }); }); + /** @test {JSONPrimitive} */ describe('JSONPrimitive', () => { it(`describes any primitive that can be part of JSON`, () => { const s: JSONPrimitive = 'string', @@ -37,6 +40,7 @@ describe('JSON', () => { }); }); + /** @test {JSONValue} */ describe('JSONValue', () => { it('describes any value that can be represented as JSON', () => { const diff --git a/spec/match.spec.ts b/spec/match.spec.ts index 6a34fb13..60552f01 100644 --- a/spec/match.spec.ts +++ b/spec/match.spec.ts @@ -4,7 +4,8 @@ import { match, TinyType } from '../src'; import { IdentityMatcher, ObjectMatcher, StringMatcher } from '../src/pattern-matching'; import { expect } from './expect'; -describe(`match`, () => { +/** @test {match} */ +describe(`::match`, () => { abstract class DomainEvent { constructor(public readonly timestamp: Date = new Date()) { diff --git a/spec/pattern-matching/IdentityMatcher.spec.ts b/spec/pattern-matching/IdentityMatcher.spec.ts index 1517522a..0a7fdaf3 100644 --- a/spec/pattern-matching/IdentityMatcher.spec.ts +++ b/spec/pattern-matching/IdentityMatcher.spec.ts @@ -3,43 +3,45 @@ import { given } from 'mocha-testdata'; import { IdentityMatcher } from '../../src/pattern-matching'; import { expect } from '../expect'; -describe('IdentityMatcher', () => { - given( - [true, 'received "true"'], - [false, 'received "false"'], - ).it('matches a boolean', (input: boolean, expected_result: string) => { - - const result = new IdentityMatcher(input) - .when(true, _ => `received "true"`) - .else(_ => `received "false"`); - - expect(result).to.equal(expected_result); - }); - - given( - [-1, 'received "-1"'], - [0.1, 'received "0.1"'], - [5, 'else, received "5"'], - // [NaN, 'received "NaN"'], - [Infinity, 'to infinity and beyond!'], - ).it('matches a number', (input: number, expected_result: string) => { - - const result = new IdentityMatcher(input) - .when(-1, _ => `received "-1"`) - .when(0.1, _ => `received "0.1"`) - .when(Infinity, _ => `to infinity and beyond!`) - .else(_ => `else, received "${_}"`); - - expect(result).to.equal(expected_result); - }); - - it('matches a symbol', () => { - const s = Symbol('some symbol'); - - const result = new IdentityMatcher(s) - .when(s, _ => `received "some symbol"`) - .else(_ => `else, received "${_}"`); - - expect(result).to.equal('received "some symbol"'); +describe('pattern-matching', () => { + describe('IdentityMatcher', () => { + given( + [true, 'received "true"'], + [false, 'received "false"'], + ).it('matches a boolean', (input: boolean, expected_result: string) => { + + const result = new IdentityMatcher(input) + .when(true, _ => `received "true"`) + .else(_ => `received "false"`); + + expect(result).to.equal(expected_result); + }); + + given( + [-1, 'received "-1"'], + [0.1, 'received "0.1"'], + [5, 'else, received "5"'], + // [NaN, 'received "NaN"'], + [Infinity, 'to infinity and beyond!'], + ).it('matches a number', (input: number, expected_result: string) => { + + const result = new IdentityMatcher(input) + .when(-1, _ => `received "-1"`) + .when(0.1, _ => `received "0.1"`) + .when(Infinity, _ => `to infinity and beyond!`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal(expected_result); + }); + + it('matches a symbol', () => { + const s = Symbol('some symbol'); + + const result = new IdentityMatcher(s) + .when(s, _ => `received "some symbol"`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal('received "some symbol"'); + }); }); }); diff --git a/spec/pattern-matching/ObjectMatcher.spec.ts b/spec/pattern-matching/ObjectMatcher.spec.ts index e3d7fec1..db6bf18d 100644 --- a/spec/pattern-matching/ObjectMatcher.spec.ts +++ b/spec/pattern-matching/ObjectMatcher.spec.ts @@ -5,134 +5,132 @@ import { TinyType } from '../../src'; import { ObjectMatcher } from '../../src/pattern-matching'; import { expect } from '../expect'; -describe('ObjectMatcher', () => { +describe('pattern-matching', () => { + describe('ObjectMatcher', () => { - describe('when working with Tiny Types', () => { + describe('when working with Tiny Types', () => { - class Name extends TinyType { - constructor(public readonly value: string) { - super(); + class Name extends TinyType { + constructor(public readonly value: string) { + super(); + } } - } - class EmailAddress extends TinyType { - constructor(public readonly value: string) { - super(); - } - } - - given( - [ new Name('Jan'), `matched "Jan"` ], - [ new Name('John'), `matched "John"` ], - [ new Name('Sara'), `else, received "Name(value=Sara)"` ], - ). - it('matches equal objects', (input: Name, expectedMessage: string) => { - const result = new ObjectMatcher(input) - .when(new Name('Jan'), _ => `matched "Jan"`) - .when(new Name('John'), _ => `matched "John"`) - .else(_ => `else, received "${_}"`); - - expect(result).to.equal(expectedMessage); - }); - - it('matches identical objects', () => { - const input = { field: 'value' }; - const result = new ObjectMatcher(input) - .when(input, _ => `matched by identity`) - .else(_ => `else, received "${_}"`); + class EmailAddress extends TinyType { + constructor(public readonly value: string) { + super(); + } + } - expect(result).to.equal(`matched by identity`); + given( + [new Name('Jan'), `matched "Jan"`], + [new Name('John'), `matched "John"`], + [new Name('Sara'), `else, received "Name(value=Sara)"`], + ).it('matches equal objects', (input: Name, expectedMessage: string) => { + const result = new ObjectMatcher(input) + .when(new Name('Jan'), _ => `matched "Jan"`) + .when(new Name('John'), _ => `matched "John"`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal(expectedMessage); + }); + + it('matches identical objects', () => { + const input = {field: 'value'}; + + const result = new ObjectMatcher(input) + .when(input, _ => `matched by identity`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal(`matched by identity`); + }); + + given( + [new Name('Jan'), `matched by equality`], + [new Name('John'), `matched by type`], + [new EmailAddress('jan@example.com'), `else, received "EmailAddress(value=jan@example.com)"`], + ).it('can be mixed', (input: Name, expectedMessage: string) => { + const result = new ObjectMatcher(input) + .when(new Name('Jan'), _ => `matched by equality`) + .when(Name, _ => `matched by type`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal(expectedMessage); + }); }); - given( - [ new Name('Jan'), `matched by equality` ], - [ new Name('John'), `matched by type` ], - [ new EmailAddress('jan@example.com'), `else, received "EmailAddress(value=jan@example.com)"` ], - ). - it('can be mixed', (input: Name, expectedMessage: string) => { - const result = new ObjectMatcher(input) - .when(new Name('Jan'), _ => `matched by equality`) - .when(Name, _ => `matched by type`) - .else(_ => `else, received "${_}"`); - - expect(result).to.equal(expectedMessage); - }); - }); + describe('when working with regular classes', () => { + abstract class DomainEvent { + constructor(public readonly timestamp: Date) { + } + } - describe('when working with regular classes', () => { - abstract class DomainEvent { - constructor(public readonly timestamp: Date) { + class AccountCreated extends DomainEvent { + constructor(public readonly account_name: string, timestamp: Date) { + super(timestamp); + } } - } - class AccountCreated extends DomainEvent { - constructor(public readonly account_name: string, timestamp: Date) { - super(timestamp); + class AccountConfirmed extends DomainEvent { + constructor(public readonly account_name: string, + public readonly email: string, + timestamp: Date,) { + super(timestamp); + } } - } - - class AccountConfirmed extends DomainEvent { - constructor(public readonly account_name: string, - public readonly email: string, - timestamp: Date, - ) { - super(timestamp); + + class UnclassifiedEvent extends DomainEvent { } - } - - class UnclassifiedEvent extends DomainEvent { - } - - given( - [ - new AccountCreated('jan-molak', new Date()), - `AccountCreated`, - ], - [ - new AccountConfirmed('jan-molak', 'jan.molak@serenity.io', new Date()), - `AccountConfirmed`, - ], - [ - new UnclassifiedEvent(new Date()), - `UnclassifiedEvent`, - ], - ). - it('matches object by constructor function', (input: DomainEvent, expected_result: string) => { - - const result = new ObjectMatcher(input) - .when(AccountCreated, _ => `AccountCreated`) - .when(AccountConfirmed, _ => `AccountConfirmed`) - .when(DomainEvent, _ => `UnclassifiedEvent`) - .else(_ => `else, received "${_}"`); - - expect(result).to.equal(expected_result); - }); - // todo: mixed constructor/tiny? - - given( - [ - new AccountCreated('jan-molak', new Date()), - `Account created for jan-molak`, - ], - [ - new AccountConfirmed('jan-molak', 'jan.molak@serenity.io', new Date()), - `Account confirmed for jan-molak at jan.molak@serenity.io`, - ], - [ - new UnclassifiedEvent(new Date()), - `Some DomainEvent received`, - ], - ). - it('matches object by constructor function', (input: DomainEvent, expected_result: string) => { - - const result = new ObjectMatcher(input) - .when(AccountCreated, ({ account_name }) => `Account created for ${ account_name }`) - .when(AccountConfirmed, ({ account_name, email }) => `Account confirmed for ${ account_name } at ${ email }`) - .when(DomainEvent, ({ timestamp }) => `Some DomainEvent received`) - .else(_ => `else, received "${_}"`); - - expect(result).to.equal(expected_result); + given( + [ + new AccountCreated('jan-molak', new Date()), + `AccountCreated`, + ], + [ + new AccountConfirmed('jan-molak', 'jan.molak@serenity.io', new Date()), + `AccountConfirmed`, + ], + [ + new UnclassifiedEvent(new Date()), + `UnclassifiedEvent`, + ], + ).it('matches object by constructor function', (input: DomainEvent, expected_result: string) => { + + const result = new ObjectMatcher(input) + .when(AccountCreated, _ => `AccountCreated`) + .when(AccountConfirmed, _ => `AccountConfirmed`) + .when(DomainEvent, _ => `UnclassifiedEvent`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal(expected_result); + }); + + // todo: mixed constructor/tiny? + + given( + [ + new AccountCreated('jan-molak', new Date()), + `Account created for jan-molak`, + ], + [ + new AccountConfirmed('jan-molak', 'jan.molak@serenity.io', new Date()), + `Account confirmed for jan-molak at jan.molak@serenity.io`, + ], + [ + new UnclassifiedEvent(new Date()), + `Some DomainEvent received`, + ], + ).it('matches object by constructor function', (input: DomainEvent, expected_result: string) => { + + const result = new ObjectMatcher(input) + .when(AccountCreated, ({account_name}) => `Account created for ${ account_name }`) + .when(AccountConfirmed, ({account_name, email}) => `Account confirmed for ${ account_name } at ${ email }`) + .when(DomainEvent, ({timestamp}) => `Some DomainEvent received`) + .else(_ => `else, received "${_}"`); + + expect(result).to.equal(expected_result); + }); }); }); }); diff --git a/spec/pattern-matching/StringMatcher.spec.ts b/spec/pattern-matching/StringMatcher.spec.ts index d759d915..8d6c25fd 100644 --- a/spec/pattern-matching/StringMatcher.spec.ts +++ b/spec/pattern-matching/StringMatcher.spec.ts @@ -3,17 +3,19 @@ import { given } from 'mocha-testdata'; import { StringMatcher } from '../../src/pattern-matching'; import { expect } from '../expect'; -describe('StringMatcher', () => { +describe('pattern-matching', () => { + describe('StringMatcher', () => { - given( - ['hello', 'matched a regular expression'], - ['hello world', 'matched the identity matcher'], - ).it('matches string and regular expressions', (input: string, expected_result: string) => { - const result = new StringMatcher(input) - .when('hello world', _ => `matched the identity matcher`) - .when(/^[Hh]ello.*$/, _ => `matched a regular expression`) - .else(_ => `else, received "${_}"`); + given( + ['hello', 'matched a regular expression'], + ['hello world', 'matched the identity matcher'], + ).it('matches string and regular expressions', (input: string, expected_result: string) => { + const result = new StringMatcher(input) + .when('hello world', _ => `matched the identity matcher`) + .when(/^[Hh]ello.*$/, _ => `matched a regular expression`) + .else(_ => `else, received "${_}"`); - expect(result).to.equal(expected_result); + expect(result).to.equal(expected_result); + }); }); }); diff --git a/spec/pattern-matching/rules/MatchesAnything.spec.ts b/spec/pattern-matching/rules/MatchesAnything.spec.ts index 2bc895ac..b4ed0b24 100644 --- a/spec/pattern-matching/rules/MatchesAnything.spec.ts +++ b/spec/pattern-matching/rules/MatchesAnything.spec.ts @@ -3,14 +3,19 @@ import { given } from 'mocha-testdata'; import { MatchesAnything } from '../../../src/pattern-matching/rules'; import { expect } from '../../expect'; -describe('MatchesAnything', () => { +describe('pattern-matching', () => { - given( - 1, false, {}, - ). - it('is always executed', (input: any) => { - const rule = new MatchesAnything(_ => _); + describe('rules', () => { - expect(rule.matches(input)).to.be.true; // tslint:disable-line:no-unused-expression + describe('MatchesAnything', () => { + + given( + 1, false, {}, + ).it('is always executed', (input: any) => { + const rule = new MatchesAnything(_ => _); + + expect(rule.matches(input)).to.be.true; // tslint:disable-line:no-unused-expression + }); + }); }); }); diff --git a/spec/predicates/and.spec.ts b/spec/predicates/and.spec.ts new file mode 100644 index 00000000..d7b84daf --- /dev/null +++ b/spec/predicates/and.spec.ts @@ -0,0 +1,43 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { and, check, isDefined, isGreaterThan, isInteger, isLessThan, or, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {and} */ + describe('::and', () => { + + class InvestmentLengthInYears extends TinyType { + constructor(public readonly value: number) { + super(); + check('InvestmentLengthInYears', value, and( + isDefined(), + isInteger(), + isGreaterThan(0), + isLessThan(50), + )); + } + } + + it('ensures that all the predicates are met', () => { + expect(new InvestmentLengthInYears(10)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + [ null, 'InvestmentLengthInYears should be defined' ], + [ 0.2, 'InvestmentLengthInYears should be an integer' ], + [ -2, 'InvestmentLengthInYears should be greater than 0' ], + [ 52, 'InvestmentLengthInYears should be less than 50' ], + ). + it('complains upon the first unmet predicate', (value: any, errorMessage: string) => { + expect(() => new InvestmentLengthInYears(value)) + .to.throw(errorMessage); + }); + + it('complains if there are no predicates specified', () => { + expect(() => and()).to.throw(`Looks like you haven't specified any predicates to check the value against?`); + }); + }); +}); diff --git a/spec/predicates/hasLengthOf.spec.ts b/spec/predicates/hasLengthOf.spec.ts new file mode 100644 index 00000000..f942e9e7 --- /dev/null +++ b/spec/predicates/hasLengthOf.spec.ts @@ -0,0 +1,66 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, hasLengthOf, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {hasLengthOf} */ + describe('::hasLengthOf', () => { + + /** @test {hasLengthOf} */ + describe('when used with strings', () => { + class Password extends TinyType { + constructor(public readonly value: string) { + super(); + + check('Password', value, hasLengthOf(8)); + } + } + + it('ensures that the value has a correct length', () => { + expect(new Password('P@ssw0rd')).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + undefined, + null, + ). + it('complains when the value is undefined', (value: any) => { + expect(() => new Password(value)).to.throw(`Password should have a length of 8`); + }); + + given( + '7_chars', + '9___chars', + ). + it('complains if the value is of incorrect length', (value: string) => { + expect(() => new Password(value)).to.throw(`Password should have a length of 8`); + }); + }); + + /** @test {hasLengthOf} */ + describe('when used with arrays', () => { + class Collection extends TinyType { + constructor(public readonly values: string[]) { + super(); + + check('Collection', values, hasLengthOf(2)); + } + } + + it('ensures that the value has a correct length', () => { + expect(new Collection(['a', 'b'])).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + ['a'], + ['a', 'b', 'c'], + ). + it('complains if the value is of incorrect length', (values: string[]) => { + expect(() => new Collection(values)).to.throw(`Collection should have a length of 2`); + }); + }); + }); +}); diff --git a/spec/predicates/isArray.spec.ts b/spec/predicates/isArray.spec.ts new file mode 100644 index 00000000..07275d79 --- /dev/null +++ b/spec/predicates/isArray.spec.ts @@ -0,0 +1,34 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isArray, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isArray} */ + describe('::isArray', () => { + + class Strings extends TinyType { + constructor(public readonly values: string[]) { + super(); + + check('Collection', values, isArray()); + } + } + + it('ensures that the argument is an array', () => { + expect(new Strings(['lorem', 'ipsum'])).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + undefined, + null, + {}, + false, + 5, + ).it('complains if the value is not an array and provides', (value: any) => { + expect(() => new Strings(value)).to.throw(`Collection should be an array`); + }); + }); +}); diff --git a/spec/predicates/isDefined.spec.ts b/spec/predicates/isDefined.spec.ts new file mode 100644 index 00000000..88e426d2 --- /dev/null +++ b/spec/predicates/isDefined.spec.ts @@ -0,0 +1,32 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isDefined, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isDefined} */ + describe('::isDefined', () => { + class UserName extends TinyType { + constructor(public readonly value: string) { + super(); + + check('UserName', value, isDefined()); + } + } + + it('ensures that the value is defined', () => { + expect(() => new UserName('Jan')).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + 'Jan', + '', + true, + false, + ).it('works for any defined value, even the "falsy" ones', (value: any) => { + expect(new UserName(value)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + }); +}); diff --git a/spec/predicates/isEqualTo.spec.ts b/spec/predicates/isEqualTo.spec.ts new file mode 100644 index 00000000..4c9ec243 --- /dev/null +++ b/spec/predicates/isEqualTo.spec.ts @@ -0,0 +1,77 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isEqualTo, TinyType, TinyTypeOf } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isEqualTo} */ + describe('::isEqualTo', () => { + + /** @test {isEqualTo} */ + describe('when working with Tiny Types', () => { + + class AccountId extends TinyTypeOf() {} + class Command extends TinyTypeOf() {} + class UpgradeAccount extends Command {} + + class AccountsService { + constructor(public readonly loggedInUser: AccountId) {} + handle(command: Command) { + check('AccountId', command.value, isEqualTo(this.loggedInUser)); + } + } + + it('ensures that objects are identical by value', () => { + const loggedInUser = new AccountId(42); + const accounts = new AccountsService(loggedInUser); + + const upgradeOwnAccount = new UpgradeAccount(loggedInUser); + + expect(accounts.handle(upgradeOwnAccount)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + it('complains if the objects are not identical by value', () => { + const hacker = new AccountId(666); + const anotherUser = new AccountId(42); + const accounts = new AccountsService(hacker); + + const upgradeAnotherAccount = new UpgradeAccount(anotherUser); + + expect(() => accounts.handle(upgradeAnotherAccount)) + .to.throw('AccountId should be equal to AccountId(value=666)'); + }); + }); + + /** @test {isEqualTo} */ + describe('when working with primitive types', () => { + + given( + null, + undefined, + Infinity, + 1, + false, + 'string', + {}, + [], + ). + it('ensures they are equal', (value: any) => { + expect(check('Val', value, isEqualTo(value))).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + 1, + false, + 'string', + {}, + [], + ). + it('complains if they are not equal', (value: any) => { + expect(() => check('Value', value, isEqualTo('expected value'))) + .to.throw('Value should be equal to expected value'); + }); + }); + }); +}); diff --git a/spec/predicates/isGreaterThan.spec.ts b/spec/predicates/isGreaterThan.spec.ts new file mode 100644 index 00000000..4c96c783 --- /dev/null +++ b/spec/predicates/isGreaterThan.spec.ts @@ -0,0 +1,38 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isGreaterThan, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isGreaterThan} */ + describe('::isGreaterThan', () => { + class InvestmentLength extends TinyType { + constructor(public readonly value: number) { + super(); + + check('InvestmentLength', value, isGreaterThan(0)); + } + } + + it('ensures that the argument is greater than a specified number', () => { + expect(new InvestmentLength(5)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + it('complains if the argument is more than a specified number', () => { + expect(() => new InvestmentLength(-1)).to.throw(`InvestmentLength should be greater than 0`); + }); + + given( + 0, + -1, + undefined, + null, + {}, + 'string', + ).it('complains if the value does not meet the predicate', (value: any) => { + expect(() => new InvestmentLength(value)).to.throw(`InvestmentLength should be greater than 0`); + }); + }); +}); diff --git a/spec/predicates/isGreaterThanOrEqualTo.spec.ts b/spec/predicates/isGreaterThanOrEqualTo.spec.ts new file mode 100644 index 00000000..34a5953f --- /dev/null +++ b/spec/predicates/isGreaterThanOrEqualTo.spec.ts @@ -0,0 +1,40 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isGreaterThanOrEqualTo, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isGreaterThanOrEqualTo} */ + describe('::isGreaterThanOrEqualTo', () => { + class InvestmentLength extends TinyType { + constructor(public readonly value: number) { + super(); + + check('InvestmentLength', value, isGreaterThanOrEqualTo(0)); + } + } + + given(0, 1). + it('ensures that the argument is greater than or equal to a specified number', (value: number) => { + expect(new InvestmentLength(value)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + it('complains if the argument is less than the lower bound', () => { + expect(() => new InvestmentLength(-1)) + .to.throw(`InvestmentLength should either be equal to 0 or be greater than 0`); + }); + + given( + -1, + undefined, + null, + {}, + 'string', + ).it('complains if the value does not meet the predicate', (value: any) => { + expect(() => new InvestmentLength(value)) + .to.throw(`InvestmentLength should either be equal to 0 or be greater than 0`); + }); + }); +}); diff --git a/spec/predicates/isInRange.spec.ts b/spec/predicates/isInRange.spec.ts new file mode 100644 index 00000000..b7d84acb --- /dev/null +++ b/spec/predicates/isInRange.spec.ts @@ -0,0 +1,47 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, TinyType } from '../../src'; +import { expect } from '../expect'; +import { isInRange } from '../../src/predicates/isInRange'; + +describe('predicates', () => { + + /** @test {isInRange} */ + describe('::isInRange', () => { + + class InvestmentLength extends TinyType { + constructor(public readonly value: number) { + super(); + + check('InvestmentLength', value, isInRange(1, 5)); + } + } + + given(1, 2, 3, 4, 5). + it('ensures that the value is within the range specified', (value: number) => { + expect(new InvestmentLength(value)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + it('complains if the value is lower than the lower bound', () => { + expect(() => new InvestmentLength(0)) + .to.throw(`InvestmentLength should either be equal to 1 or be greater than 1`); + }); + + it('complains if the value is greater than the upper bound', () => { + expect(() => new InvestmentLength(6)) + .to.throw(`InvestmentLength should either be less than 5 or be equal to 5`); + }); + + given( + undefined, + null, + {}, + false, + ). + it('complains if the value is of a wrong type', (value: any) => { + expect(() => new InvestmentLength(value)) + .to.throw(`InvestmentLength should either be equal to 1 or be greater than 1`); + }); + }); +}); diff --git a/spec/predicates/isInteger.spec.ts b/spec/predicates/isInteger.spec.ts new file mode 100644 index 00000000..6e4a9d49 --- /dev/null +++ b/spec/predicates/isInteger.spec.ts @@ -0,0 +1,36 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isInteger, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isInteger} */ + describe('::isInteger', () => { + class AgeInYears extends TinyType { + constructor(public readonly value: number) { + super(); + + check('AgeInYears', value, isInteger()); + } + } + + it('ensures that the argument in an integer', () => { + expect(new AgeInYears(42)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + 1 / 3, + 0.42, + undefined, + null, + NaN, + Infinity, + {}, + 'string', + ).it('complains if the value is not an integer', (value: any) => { + expect(() => new AgeInYears(value)).to.throw(`AgeInYears should be an integer`); + }); + }); +}); diff --git a/spec/predicates/isLessThan.spec.ts b/spec/predicates/isLessThan.spec.ts new file mode 100644 index 00000000..80d5ddab --- /dev/null +++ b/spec/predicates/isLessThan.spec.ts @@ -0,0 +1,36 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isLessThan, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isLessThan} */ + describe('::isLessThan', () => { + class InvestmentLength extends TinyType { + constructor(public readonly value: number) { + super(); + + check('InvestmentLength', value, isLessThan(50)); + } + } + + it('ensures that the argument is less than a specified number', () => { + expect(new InvestmentLength(5)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + it('complains if the argument is more than a specified number', () => { + expect(() => new InvestmentLength(55)).to.throw(`InvestmentLength should be less than 50`); + }); + + given( + undefined, + null, + {}, + 'string', + ).it('complains if the value is not an integer', (value: any) => { + expect(() => new InvestmentLength(value)).to.throw(`InvestmentLength should be less than 50`); + }); + }); +}); diff --git a/spec/predicates/isLessThanOrEqual.spec.ts b/spec/predicates/isLessThanOrEqual.spec.ts new file mode 100644 index 00000000..550947f1 --- /dev/null +++ b/spec/predicates/isLessThanOrEqual.spec.ts @@ -0,0 +1,39 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isLessThanOrEqualTo, TinyType } from '../../src'; +import { expect } from '../expect'; + +describe('predicates', () => { + + /** @test {isLessThanOrEqualTo} */ + describe('::isLessThanOrEqualTo', () => { + class InvestmentLength extends TinyType { + constructor(public readonly value: number) { + super(); + + check('InvestmentLength', value, isLessThanOrEqualTo(50)); + } + } + + given(49, 50). + it('ensures that the argument is less than or equal to the upper bound', (value: number) => { + expect(new InvestmentLength(value)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + it('complains if the argument is greater than the upper bound', () => { + expect(() => new InvestmentLength(55)) + .to.throw(`InvestmentLength should either be less than 50 or be equal to 50`); + }); + + given( + undefined, + null, + {}, + 'string', + ).it('complains if the value is not an integer', (value: any) => { + expect(() => new InvestmentLength(value)) + .to.throw(`InvestmentLength should either be less than 50 or be equal to 50`); + }); + }); +}); diff --git a/spec/predicates/or.spec.ts b/spec/predicates/or.spec.ts new file mode 100644 index 00000000..49c4231e --- /dev/null +++ b/spec/predicates/or.spec.ts @@ -0,0 +1,51 @@ +import 'mocha'; +import { given } from 'mocha-testdata'; + +import { check, isDefined, isEqualTo, isGreaterThan, isInteger, isLessThan, or, TinyType } from '../../src'; +import { expect } from '../expect'; +import { Failure } from '../../src/predicates'; + +describe('predicates', () => { + + /** @test {or} */ + describe('::or', () => { + + class Percentage extends TinyType { + constructor(public readonly value: number) { + super(); + check('Percentage', value, + isDefined(), + isInteger(), + or(isEqualTo(0), isGreaterThan(0)), + or(isLessThan(100), isEqualTo(100)), + ); + } + } + + given(0, 1, 99, 100). + it('ensures that at least one of the `or` predicates is met', (value: number) => { + expect(new Percentage(value)).to.not.throw; // tslint:disable-line:no-unused-expression + }); + + given( + [-1, 'Percentage should either be equal to 0 or be greater than 0'], + [101, 'Percentage should either be less than 100 or be equal to 100'], + ). + it('complains if at least one of the `or` predicates is not met', (value: number, errorMessage: string) => { + expect(() => new Percentage(value)) + .to.throw(errorMessage); + }); + + it('complains if there are no predicates specified', () => { + expect(() => or()).to.throw(`Looks like you haven't specified any predicates to check the value against?`); + }); + + it('concatenates the error messages in a human-friendly way', () => { + expect(() => check('Project name', 'node.js', + or(isEqualTo('Serenity/JS'), isEqualTo('TinyTypes'), isEqualTo('Build Monitor')), + )).to.throw( + 'Project name should either be equal to Serenity/JS, be equal to TinyTypes or be equal to Build Monitor', + ); + }); + }); +}); diff --git a/src/TinyType.ts b/src/TinyType.ts index 89da8d65..d7cf0a3a 100644 --- a/src/TinyType.ts +++ b/src/TinyType.ts @@ -4,6 +4,8 @@ import { JSONValue } from './types'; * @desc The {@link TinyTypeOf} can be used to define simple * single-value {@link TinyType}s on a single line. * + * @experimental + * * @example * class Username extends TinyTypeOf() {} * diff --git a/src/check.ts b/src/check.ts new file mode 100644 index 00000000..fb26c150 --- /dev/null +++ b/src/check.ts @@ -0,0 +1,46 @@ +import { Failure, isArray, isDefined, isGreaterThan, Predicate } from './predicates'; + +/** + * @desc The `check` function verifies if the value meets the specified {Predicate}s. + * + * @example Basic usage + * import { check, isDefined } from 'tiny-types' + * + * const username = 'jan-molak' + * check('Username', username, isDefined()); + * + * @example Ensuring validity of a domain object upon creation + * import { TinyType, check, isDefined, isInt, isInRange } from 'tiny-types' + * + * class Age extends TinyType { + * constructor(public readonly value: number) { + * check('Age', value, isDefined(), isInt(), isInRange(0, 125)); + * } + * } + * + * @param {string} name - the name of the value to check. + * This name will be included in the error message should the check fail + * @param {T} value - the argument to check + * @param {...Array>} predicates - a list of predicates to check the value against + * @returns {T} - if the original value passes all the predicates, it's returned from the function + */ +export function check(name: string, value: T, ...predicates: Array>): T { + if ([ + _ => isDefined().check(_), + _ => isArray().check(_), + _ => isGreaterThan(0).check(_.length), + ].some(c => c(predicates) instanceof Failure) + ) { + throw new Error(`Looks like you haven't specified any predicates to check the value of ${name} against?`); + } + + const firstUnmet = predicates + .map(predicate => predicate.check(value)) + .find(result => result instanceof Failure) as Failure; + + if (!! firstUnmet) { + throw new Error(`${ name } should ${ firstUnmet.description }`); + } + + return value; +} diff --git a/src/index.ts b/src/index.ts index 3df2340f..efbb97b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +export * from './check'; +export * from './predicates'; export * from './match'; export * from './pattern-matching/PatternMatcher'; export * from './TinyType'; diff --git a/src/match.ts b/src/match.ts index 80ae2ee0..0c16c5ba 100644 --- a/src/match.ts +++ b/src/match.ts @@ -54,6 +54,12 @@ export function match(_: Input_Type): { ) => PatternMatcher, TinyType | Input_Type, Output_Type>, }; +/** + * @experimental + * + * @param value + * @returns {PatternMatcher} + */ export function match(value: any): PatternMatcher { switch (true) { case typeof value === 'string': diff --git a/src/pattern-matching/IdentityMatcher.ts b/src/pattern-matching/IdentityMatcher.ts index d21e4063..1233d759 100644 --- a/src/pattern-matching/IdentityMatcher.ts +++ b/src/pattern-matching/IdentityMatcher.ts @@ -1,6 +1,9 @@ import { PatternMatcher } from './PatternMatcher'; import { MatchesIdentical } from './rules'; +/** + * @access private + */ export class IdentityMatcher extends PatternMatcher { when(pattern: Input_Type, transformation: (v: Input_Type) => Output_Type): PatternMatcher { diff --git a/src/pattern-matching/ObjectMatcher.ts b/src/pattern-matching/ObjectMatcher.ts index af0192ac..4b93b14f 100644 --- a/src/pattern-matching/ObjectMatcher.ts +++ b/src/pattern-matching/ObjectMatcher.ts @@ -3,6 +3,9 @@ import { ConstructorOrAbstract } from '../types'; import { PatternMatcher } from './PatternMatcher'; import { MatcherRule, MatchesEqualTinyType, MatchesIdentical, MatchesObjectsWithCommonPrototype } from './rules'; +/** + * @access private + */ export class ObjectMatcher extends PatternMatcher, TinyType | Input_Type, Output_Type> { when(pattern: ConstructorOrAbstract, transformation: (v: MT) => Output_Type): ObjectMatcher; diff --git a/src/pattern-matching/PatternMatcher.ts b/src/pattern-matching/PatternMatcher.ts index 228de3aa..582737a0 100644 --- a/src/pattern-matching/PatternMatcher.ts +++ b/src/pattern-matching/PatternMatcher.ts @@ -1,6 +1,9 @@ import { List } from '../types'; import { MatcherRule, MatchesAnything } from './rules'; +/** + * @access private + */ export abstract class PatternMatcher { constructor(protected readonly value: Input_Type, protected readonly rules: List> = []) { diff --git a/src/pattern-matching/StringMatcher.ts b/src/pattern-matching/StringMatcher.ts index 8ee99224..b49f3ae2 100644 --- a/src/pattern-matching/StringMatcher.ts +++ b/src/pattern-matching/StringMatcher.ts @@ -1,6 +1,9 @@ import { PatternMatcher } from './PatternMatcher'; import { MatchesIdentical, MatchesRegExp } from './rules'; +/** + * @access private + */ export class StringMatcher extends PatternMatcher { when(pattern: string | RegExp, transformation: (v: string) => Output_Type): PatternMatcher { diff --git a/src/pattern-matching/rules/MatcherRule.ts b/src/pattern-matching/rules/MatcherRule.ts index 00c38c6f..e8b82ecf 100644 --- a/src/pattern-matching/rules/MatcherRule.ts +++ b/src/pattern-matching/rules/MatcherRule.ts @@ -1,3 +1,6 @@ +/** + * @access private + */ export abstract class MatcherRule{ constructor( private readonly transformation: (v: Input_Type) => Output_Type, diff --git a/src/pattern-matching/rules/MatchesAnything.ts b/src/pattern-matching/rules/MatchesAnything.ts index b4bee2ee..3b663bbc 100644 --- a/src/pattern-matching/rules/MatchesAnything.ts +++ b/src/pattern-matching/rules/MatchesAnything.ts @@ -1,5 +1,8 @@ import { MatcherRule } from './MatcherRule'; +/** + * @access private + */ export class MatchesAnything extends MatcherRule { constructor(transformation: (v: Input_Type) => Output_Type) { super(transformation); diff --git a/src/pattern-matching/rules/MatchesEqualTinyType.ts b/src/pattern-matching/rules/MatchesEqualTinyType.ts index cca4836b..229d4368 100644 --- a/src/pattern-matching/rules/MatchesEqualTinyType.ts +++ b/src/pattern-matching/rules/MatchesEqualTinyType.ts @@ -1,6 +1,9 @@ import { TinyType } from '../../TinyType'; import { MatcherRule } from './MatcherRule'; +/** + * @access private + */ export class MatchesEqualTinyType extends MatcherRule { constructor(private readonly pattern: TinyType, transformation: (v: TinyType) => Output_Type) { super(transformation); diff --git a/src/pattern-matching/rules/MatchesIdentical.ts b/src/pattern-matching/rules/MatchesIdentical.ts index c604679a..f8b9864b 100644 --- a/src/pattern-matching/rules/MatchesIdentical.ts +++ b/src/pattern-matching/rules/MatchesIdentical.ts @@ -1,5 +1,8 @@ import { MatcherRule } from './MatcherRule'; +/** + * @access private + */ export class MatchesIdentical extends MatcherRule { constructor(private readonly pattern: Input_Type, transformation: (v: Input_Type) => Output_Type) { super(transformation); diff --git a/src/pattern-matching/rules/MatchesObjectsWithCommonPrototype.ts b/src/pattern-matching/rules/MatchesObjectsWithCommonPrototype.ts index 0df8c27a..f63f3b95 100644 --- a/src/pattern-matching/rules/MatchesObjectsWithCommonPrototype.ts +++ b/src/pattern-matching/rules/MatchesObjectsWithCommonPrototype.ts @@ -1,6 +1,9 @@ import { ConstructorOrAbstract } from '../../types'; import { MatcherRule } from './MatcherRule'; +/** + * @access private + */ export class MatchesObjectsWithCommonPrototype extends MatcherRule { constructor(private readonly pattern: ConstructorOrAbstract, transformation: (v: Input_Type) => Output_Type) { super(transformation); diff --git a/src/pattern-matching/rules/MatchesRegExp.ts b/src/pattern-matching/rules/MatchesRegExp.ts index c6486884..64299fd7 100644 --- a/src/pattern-matching/rules/MatchesRegExp.ts +++ b/src/pattern-matching/rules/MatchesRegExp.ts @@ -1,5 +1,8 @@ import { MatcherRule } from './MatcherRule'; +/** + * @access private + */ export class MatchesRegExp extends MatcherRule { constructor(private readonly pattern: RegExp, transformation: (v: string) => Output_Type) { super(transformation); diff --git a/src/predicates/Predicate.ts b/src/predicates/Predicate.ts new file mode 100644 index 00000000..01a51bfc --- /dev/null +++ b/src/predicates/Predicate.ts @@ -0,0 +1,46 @@ +/** + * @access private + */ +export abstract class Result { + constructor(public readonly value: T) {} +} + +/** + * @access private + */ +export class Success extends Result {} + +/** + * @access private + */ +export class Failure extends Result { + constructor(value: T, public readonly description: string) { + super(value); + } +} + +/** + * @access private + */ +export class SingleConditionPredicate implements Predicate { + static to(description: string, condition: (value: V) => boolean) { + return new SingleConditionPredicate(description, condition); + } + + constructor(private readonly description: string, + private readonly isMetBy: (value: T) => boolean, + ) {} + + check(value: T): Result { + return this.isMetBy(value) + ? new Success(value) + : new Failure(value, this.description); + } +} + +/** + * @access public + */ +export interface Predicate { + check(value: T): Result; +} diff --git a/src/predicates/and.ts b/src/predicates/and.ts new file mode 100644 index 00000000..48b3ab5e --- /dev/null +++ b/src/predicates/and.ts @@ -0,0 +1,46 @@ +import { isArray } from './isArray'; +import { isDefined } from './isDefined'; +import { isGreaterThan } from './isGreaterThan'; +import { Failure, Predicate, Result, Success } from './Predicate'; + +/** + * @desc Checks if the `value` meets all the provided {@link Predicate}s. + * + * @example + * import { and, check, isDefined, isGreaterThan, isInteger, TinyType } from 'tiny-types'; + * + * class AgeInYears extends TinyType { + * constructor(public readonly value: number) { + * check('Percentage', value, and(isDefined(), isInteger(), isGreaterThan(18)); + * } + * } + * + * @param {...Array>} predicates + * @returns {Predicate} + */ +export function and(...predicates: Array>): Predicate { + return new And(predicates); +} + +/** @access private */ +class And implements Predicate { + + constructor(private readonly predicates: Array>) { + if ([ + _ => isDefined().check(_), + _ => isArray().check(_), + _ => isGreaterThan(0).check(_.length), + ].some(check => check(this.predicates) instanceof Failure) + ) { + throw new Error(`Looks like you haven\'t specified any predicates to check the value against?`); + } + } + + check(value: T): Result { + const firstUnmet = this.predicates + .map(predicate => predicate.check(value)) + .find(result => result instanceof Failure); + + return firstUnmet || new Success(value); + } +} diff --git a/src/predicates/hasLengthOf.ts b/src/predicates/hasLengthOf.ts new file mode 100644 index 00000000..7b719c6d --- /dev/null +++ b/src/predicates/hasLengthOf.ts @@ -0,0 +1,38 @@ +import { Predicate, SingleConditionPredicate } from './Predicate'; + +export interface HasLength { length: number; } + +/** + * @desc Checks if the `value` is of `expectedLength`. + * Applies to {@link String}s, {@link Array}s and anything that has a `.length` property. + * + * @example Array + * import { check, hasLengthOf, TinyType } from 'tiny-types'; + * + * class Tuple extends TinyType { + * constructor(public readonly values: any[]) { + * super(); + * check('Tuple', values, hasLengthOf(2)); + * } + * } + * + * @example String + * import { check, hasLengthOf, TinyType } from 'tiny-types'; + * + * class Username extends TinyType { + * constructor(public readonly value: string) { + * super(); + * check('Username', value, hasLengthOf(8)); + * } + * } + * + * @param {number} expectedLength + * @returns {Predicate} + */ +export function hasLengthOf(expectedLength: number): Predicate { + const actualLengthOf = (value: HasLength) => (!! value && value.length); + + return SingleConditionPredicate.to(`have a length of ${ expectedLength }`, (value: HasLength) => + actualLengthOf(value) === expectedLength, + ); +} diff --git a/src/predicates/index.ts b/src/predicates/index.ts new file mode 100644 index 00000000..fc4a4431 --- /dev/null +++ b/src/predicates/index.ts @@ -0,0 +1,12 @@ +export * from './and'; +export * from './hasLengthOf'; +export * from './isArray'; +export * from './isDefined'; +export * from './isEqualTo'; +export * from './isGreaterThan'; +export * from './isGreaterThanOrEqualTo'; +export * from './isInteger'; +export * from './isLessThan'; +export * from './isLessThanOrEqualTo'; +export * from './or'; +export * from './Predicate'; diff --git a/src/predicates/isArray.ts b/src/predicates/isArray.ts new file mode 100644 index 00000000..2baf3fc3 --- /dev/null +++ b/src/predicates/isArray.ts @@ -0,0 +1,22 @@ +import { Predicate, SingleConditionPredicate } from './Predicate'; + +/** + * @desc Checks if the `value` is an {@link Array}. + * + * @example + * import { check, isArray, TinyType, TinyTypeOf } from 'tiny-types'; + * + * class Name extends TinyTypeOf() {} + * + * class Names extends TinyType { + * constructor(public readonly values: Name[]) { + * super(); + * check('Names', values, isArray()); + * } + * } + * + * @returns {Predicate} + */ +export function isArray(): Predicate { + return SingleConditionPredicate.to(`be an array`, (value: T[]) => Array.isArray(value)); +} diff --git a/src/predicates/isDefined.ts b/src/predicates/isDefined.ts new file mode 100644 index 00000000..a770e3e3 --- /dev/null +++ b/src/predicates/isDefined.ts @@ -0,0 +1,21 @@ +import { Predicate, SingleConditionPredicate } from './Predicate'; + +/** + * @desc Checks if the `value` is defined as anything other than {@link null} or {@link undefined}. + * + * @example + * import { check, isDefined, TinyType } from 'tiny-types'; + * + * class Name extends TinyType { + * constructor(public readonly value: string) { + * check('Name', value, isDefined()); + * } + * } + * + * @returns {Predicate} + */ +export function isDefined(): Predicate { + return SingleConditionPredicate.to(`be defined`, (value: T) => + ! (value === null || value === undefined), + ); +} diff --git a/src/predicates/isEqualTo.ts b/src/predicates/isEqualTo.ts new file mode 100644 index 00000000..55950db5 --- /dev/null +++ b/src/predicates/isEqualTo.ts @@ -0,0 +1,43 @@ +import { TinyType } from '../TinyType'; +import { Predicate, SingleConditionPredicate } from './Predicate'; + +export function isEqualTo(expectedValue: TinyType): Predicate; +export function isEqualTo(expectedValue: T): Predicate; + +/** + * @desc Checks if the `value` is equal to `expectedValue`. + * This {@link Predicate} is typically used in combination with other {@link Predicate}s. + * + * @example Comparing Tiny Types + * import { check, isEqualTo, TinyType, TinyTypeOf } from 'tiny-types'; + * + * class AccountId extends TinyTypeOf() {} + * class Command extends TinyTypeOf() {} + * class UpgradeAccount extends Command {} + * + * class AccountsService { + * constructor(public readonly loggedInUser: AccountId) {} + * handle(command: Command) { + * check('AccountId', command.value, isEqualTo(this.loggedInUser)); + * } + * } + * + * @example Comparing primitives + * import { check, isEqualTo, TinyType } from 'tiny-types'; + * + * class Admin extends TinyType { + * constructor(public readonly id: number) { + * check('Admin::id', id, isEqualTo(1)); + * } + * } + * + * @param {string | number | symbol | TinyType | object} expectedValue + * @returns {Predicate} + */ +export function isEqualTo(expectedValue: any): Predicate { + return SingleConditionPredicate.to(`be equal to ${ expectedValue }`, (value: any) => + (!! value && value.equals && expectedValue && expectedValue.equals) + ? value.equals(expectedValue) + : value === expectedValue, + ); +} diff --git a/src/predicates/isGreaterThan.ts b/src/predicates/isGreaterThan.ts new file mode 100644 index 00000000..abb138d7 --- /dev/null +++ b/src/predicates/isGreaterThan.ts @@ -0,0 +1,24 @@ +import { Predicate, SingleConditionPredicate } from './Predicate'; + +/** + * @desc Checks if the `value` is greater than the `lowerBound`. + * + * @example + * import { check, isGreaterThan, TinyType } from 'tiny-types'; + * + * class AgeInYears extends TinyType { + * constructor(public readonly value: number) { + * check('Age in years', value, isGreaterThan(0)); + * } + * } + * + * @param {number} lowerBound + * @returns {Predicate} + */ +export function isGreaterThan(lowerBound: number): Predicate { + return SingleConditionPredicate.to(`be greater than ${ lowerBound }`, (value: number) => + typeof value === 'number' && + isFinite(value) && + lowerBound < value, + ); +} diff --git a/src/predicates/isGreaterThanOrEqualTo.ts b/src/predicates/isGreaterThanOrEqualTo.ts new file mode 100644 index 00000000..15d2ffc3 --- /dev/null +++ b/src/predicates/isGreaterThanOrEqualTo.ts @@ -0,0 +1,23 @@ +import { isEqualTo } from './isEqualTo'; +import { isGreaterThan } from './isGreaterThan'; +import { or } from './or'; +import { Predicate } from './Predicate'; + +/** + * @desc Checks if the `value` is greater than or equal to the `lowerBound`. + * + * @example + * import { check, isGreaterThanOrEqualTo, TinyType } from 'tiny-types'; + * + * class AgeInYears extends TinyType { + * constructor(public readonly value: number) { + * check('Age in years', value, isGreaterThanOrEqualTo(18)); + * } + * } + * + * @param {number} lowerBound + * @returns {Predicate} + */ +export function isGreaterThanOrEqualTo(lowerBound: number): Predicate { + return or(isEqualTo(lowerBound), isGreaterThan(lowerBound)); +} diff --git a/src/predicates/isInRange.ts b/src/predicates/isInRange.ts new file mode 100644 index 00000000..298fb863 --- /dev/null +++ b/src/predicates/isInRange.ts @@ -0,0 +1,25 @@ +import { and } from './and'; +import { isGreaterThanOrEqualTo } from './isGreaterThanOrEqualTo'; +import { isLessThanOrEqualTo } from './isLessThanOrEqualTo'; +import { Predicate } from './Predicate'; + +/** + * @desc Checks if the `value` is greater than or equal to the `lowerBound` and less than or equal to the `upperBound` + * + * @example + * import { check, isInRange, TinyType } from 'tiny-types'; + * + * class InvestmentLengthInYears extends TinyType { + * constructor(public readonly value: number) { + * super(); + * check('InvestmentLengthInYears', value, isInRange(1, 5)); + * } + * } + * + * @param {number} lowerBound + * @param {number} upperBound + * @returns {Predicate} + */ +export function isInRange(lowerBound: number, upperBound: number): Predicate { + return and(isGreaterThanOrEqualTo(lowerBound), isLessThanOrEqualTo(upperBound)); +} diff --git a/src/predicates/isInteger.ts b/src/predicates/isInteger.ts new file mode 100644 index 00000000..21be861e --- /dev/null +++ b/src/predicates/isInteger.ts @@ -0,0 +1,23 @@ +import { Predicate, SingleConditionPredicate } from './Predicate'; + +/** + * @desc Checks if the `value` is an integer {@link Number}. + * + * @example + * import { and, isInteger, TinyType } from 'tiny-types'; + * + * class AgeInYears extends TinyType { + * constructor(public readonly value: number) { + * check('Age in years', value, isInteger()); + * } + * } + * + * @returns {Predicate} + */ +export function isInteger(): Predicate { + return SingleConditionPredicate.to(`be an integer`, (value: number) => + typeof value === 'number' && + isFinite(value) && + Math.floor(value) === value, + ); +} diff --git a/src/predicates/isLessThan.ts b/src/predicates/isLessThan.ts new file mode 100644 index 00000000..fb87c349 --- /dev/null +++ b/src/predicates/isLessThan.ts @@ -0,0 +1,24 @@ +import { Predicate, SingleConditionPredicate } from './Predicate'; + +/** + * @desc Checks if the `value` is less than the `upperBound`. + * + * @example + * import { check, isLessThan, TinyType } from 'tiny-types'; + * + * class InvestmentPeriodInYears extends TinyType { + * constructor(public readonly value: number) { + * check('Investment period in years', value, isLessThan(50)); + * } + * } + * + * @param {number} upperBound + * @returns {Predicate} + */ +export function isLessThan(upperBound: number): Predicate { + return SingleConditionPredicate.to(`be less than ${ upperBound }`, (value: number) => + typeof value === 'number' && + isFinite(value) && + value < upperBound, + ); +} diff --git a/src/predicates/isLessThanOrEqualTo.ts b/src/predicates/isLessThanOrEqualTo.ts new file mode 100644 index 00000000..76436072 --- /dev/null +++ b/src/predicates/isLessThanOrEqualTo.ts @@ -0,0 +1,23 @@ +import { isEqualTo } from './isEqualTo'; +import { isLessThan } from './isLessThan'; +import { or } from './or'; +import { Predicate } from './Predicate'; + +/** + * @desc Checks if the `value` is less than or equal to the `upperBound`. + * + * @example + * import { check, isLessThanOrEqualTo, TinyType } from 'tiny-types'; + * + * class InvestmentPeriod extends TinyType { + * constructor(public readonly value: number) { + * check('InvestmentPeriod', value, isLessThanOrEqualTo(50)); + * } + * } + * + * @param {number} upperBound + * @returns {Predicate} + */ +export function isLessThanOrEqualTo(upperBound: number): Predicate { + return or(isLessThan(upperBound), isEqualTo(upperBound)); +} diff --git a/src/predicates/or.ts b/src/predicates/or.ts new file mode 100644 index 00000000..f5a903c9 --- /dev/null +++ b/src/predicates/or.ts @@ -0,0 +1,52 @@ +import { isArray } from './isArray'; +import { isDefined } from './isDefined'; +import { isGreaterThan } from './isGreaterThan'; +import { Failure, Predicate, Result, Success } from './Predicate'; + +/** + * @desc Checks if the `value` meets at least one of the provided {@link Predicate}s. + * + * @example + * import { check, isEqualTo, isGreaterThan, isLessThan, or } from 'tiny-type'l + * + * class Percentage extends TinyType { + * constructor(public readonly value: number) { + * check('Percentage', value, or(isEqualTo(0), isGreaterThan(0)), or(isLessThan(100), isEqualTo(100)); + * } + * } + * + * @param {Predicate} predicates + * @returns {Predicate} + */ +export function or(...predicates: Array>): Predicate { + return new Or(predicates); +} + +/** @access private */ +class Or implements Predicate { + + constructor(private readonly predicates: Array>) { + if ([ + _ => isDefined().check(_), + _ => isArray().check(_), + _ => isGreaterThan(0).check(_.length), + ].some(check => check(this.predicates) instanceof Failure) + ) { + throw new Error(`Looks like you haven\'t specified any predicates to check the value against?`); + } + } + + check(value: T): Result { + const results = this.predicates.map(predicate => predicate.check(value)); + const anySuccess = results.some(result => result instanceof Success); + + const failures = results.filter(_ => _ instanceof Failure) + .map((_: Result) => (_ as Failure).description); + + const describe = (issues: string[]) => `either ${issues.join(', ').replace(/,([^,]*)$/, ' or$1')}`; + + return anySuccess + ? new Success(value) + : new Failure(value, describe(failures)); + } +}