From 9fde17c3a61f90456ee57823915c55a52c202d4c Mon Sep 17 00:00:00 2001 From: gcanti Date: Sat, 2 May 2020 14:05:24 +0200 Subject: [PATCH] add `Eq` experimental module --- CHANGELOG.md | 1 + Eq.md | 70 ++++++++++++ README.md | 2 + Schema.md | 2 + docs/modules/Eq.ts.md | 183 ++++++++++++++++++++++++++++++++ docs/modules/Guard.ts.md | 2 +- docs/modules/PathReporter.ts.md | 2 +- docs/modules/Reporter.ts.md | 2 +- docs/modules/Schema.ts.md | 2 +- docs/modules/Schemable.ts.md | 2 +- docs/modules/Tree.ts.md | 2 +- docs/modules/index.ts.md | 2 +- package.json | 2 +- src/Eq.ts | 163 ++++++++++++++++++++++++++++ test/Eq.ts | 81 ++++++++++++++ test/Schema.ts | 12 ++- 16 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 Eq.md create mode 100644 docs/modules/Eq.ts.md create mode 100644 src/Eq.ts create mode 100644 test/Eq.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 00bbe4e4e..779270c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ # 2.2.2 - **Experimental** + - add `Eq` module (@gcanti) - `Decoder` - add `DecodeError` interface (@gcanti) diff --git a/Eq.md b/Eq.md new file mode 100644 index 000000000..0a3828586 --- /dev/null +++ b/Eq.md @@ -0,0 +1,70 @@ + + + +- [Eq interface](#eq-interface) +- [Built-in primitive eqs](#built-in-primitive-eqs) +- [Combinators](#combinators) + + + +# Eq interface + +```ts +export interface Eq { + readonly equals: (x: A, y: A) => boolean +} +``` + +The `Eq` type class represents types which support decidable equality. + +Instances must satisfy the following laws: + +1. Reflexivity: `E.equals(a, a) === true` +2. Symmetry: `E.equals(a, b) === E.equals(b, a)` +3. Transitivity: if `E.equals(a, b) === true` and `E.equals(b, c) === true`, then `E.equals(a, c) === true` + +**Example** + +```ts +import { Eq } from 'fp-ts/lib/Eq' + +export const string: Eq = { + equals: (x, y) => x === y +} +``` + +# Built-in primitive eqs + +- `string: Eq` +- `number: Eq` +- `boolean: Eq` +- `UnknownArray: Eq>` +- `UnknownRecord: Eq>` + +# Combinators + +- `literal` +- `nullable` +- `type` +- `partial` +- `record` +- `array` +- `tuple` +- `intersection` +- `sum` +- `lazy` + +**Example** + +```ts +import * as E from 'io-ts/lib/Eq' + +const Person = E.type({ + name: E.string, + age: E.number +}) + +console.log(Person.equals({ name: 'a', age: 0 }, { name: 'a', age: 0 })) // => true +console.log(Person.equals({ name: 'a', age: 0 }, { name: '', age: 0 })) // => false +console.log(Person.equals({ name: 'a', age: 0 }, { name: 'a', age: 1 })) // => false +``` diff --git a/README.md b/README.md index 3971ad76b..5d9cb74f0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - [Installation](#installation) - [Documentation](#documentation) + - [Usage](#usage) @@ -38,4 +39,5 @@ Experimental features are published in order to get early feedback from the comm - [`Decoder`](Decoder.md) - [`Encoder`](Encoder.md) - [`Codec`](Codec.md) +- [`Eq`](Eq.md) - [`Schema` (advanced feature)](Schema.md) diff --git a/Schema.md b/Schema.md index b25172325..afc0546e7 100644 --- a/Schema.md +++ b/Schema.md @@ -8,6 +8,7 @@ import * as D from 'io-ts/lib/Decoder' import * as E from 'io-ts/lib/Encoder' import * as C from 'io-ts/lib/Codec' import * as G from 'io-ts/lib/Guard' +import * as Eq from 'io-ts/lib/Eq' export const Person = S.make((S) => S.type({ @@ -20,4 +21,5 @@ export const PersonDecoder = Person(D.decoder) export const PersonEncoder = Person(E.encoder) export const PersonCodec = Person(C.codec) export const PersonGuard = Person(G.guard) +export const PersonEq = Person(Eq.eq) ``` diff --git a/docs/modules/Eq.ts.md b/docs/modules/Eq.ts.md new file mode 100644 index 000000000..dd505bb33 --- /dev/null +++ b/docs/modules/Eq.ts.md @@ -0,0 +1,183 @@ +--- +title: Eq.ts +nav_order: 4 +parent: Modules +--- + +# Eq overview + +Added in v2.2.2 + +--- + +

Table of contents

+ +- [UnknownArray](#unknownarray) +- [UnknownRecord](#unknownrecord) +- [array](#array) +- [boolean](#boolean) +- [eq](#eq) +- [intersection](#intersection) +- [lazy](#lazy) +- [nullable](#nullable) +- [number](#number) +- [partial](#partial) +- [record](#record) +- [string](#string) +- [sum](#sum) +- [tuple](#tuple) +- [type](#type) + +--- + +# UnknownArray + +**Signature** + +```ts +export declare const UnknownArray: E.Eq +``` + +Added in v2.2.2 + +# UnknownRecord + +**Signature** + +```ts +export declare const UnknownRecord: E.Eq> +``` + +Added in v2.2.2 + +# array + +**Signature** + +```ts +export declare const array:
(eq: E.Eq) => E.Eq +``` + +Added in v2.2.2 + +# boolean + +**Signature** + +```ts +export declare const boolean: E.Eq +``` + +Added in v2.2.2 + +# eq + +**Signature** + +```ts +export declare const eq: Contravariant1<'Eq'> & S.Schemable<'Eq'> +``` + +Added in v2.2.2 + +# intersection + +**Signature** + +```ts +export declare function intersection(left: Eq, right: Eq): Eq +``` + +Added in v2.2.2 + +# lazy + +**Signature** + +```ts +export declare function lazy(f: () => Eq): Eq +``` + +Added in v2.2.2 + +# nullable + +**Signature** + +```ts +export declare function nullable(or: Eq): Eq +``` + +Added in v2.2.2 + +# number + +**Signature** + +```ts +export declare const number: E.Eq +``` + +Added in v2.2.2 + +# partial + +**Signature** + +```ts +export declare function partial(properties: { [K in keyof A]: Eq }): Eq> +``` + +Added in v2.2.2 + +# record + +**Signature** + +```ts +export declare const record: (codomain: E.Eq) => E.Eq> +``` + +Added in v2.2.2 + +# string + +**Signature** + +```ts +export declare const string: E.Eq +``` + +Added in v2.2.2 + +# sum + +**Signature** + +```ts +export declare function sum( + tag: T +): (members: { [K in keyof A]: Eq> }) => Eq +``` + +Added in v2.2.2 + +# tuple + +**Signature** + +```ts +export declare const tuple: (...components: { [K in keyof A]: E.Eq }) => E.Eq +``` + +Added in v2.2.2 + +# type + +**Signature** + +```ts +export declare const type: (eqs: { [K in keyof A]: E.Eq }) => E.Eq +``` + +Added in v2.2.2 diff --git a/docs/modules/Guard.ts.md b/docs/modules/Guard.ts.md index 4bd61f1f4..96f5b3913 100644 --- a/docs/modules/Guard.ts.md +++ b/docs/modules/Guard.ts.md @@ -1,6 +1,6 @@ --- title: Guard.ts -nav_order: 4 +nav_order: 5 parent: Modules --- diff --git a/docs/modules/PathReporter.ts.md b/docs/modules/PathReporter.ts.md index 4116f4410..e46f4619e 100644 --- a/docs/modules/PathReporter.ts.md +++ b/docs/modules/PathReporter.ts.md @@ -1,6 +1,6 @@ --- title: PathReporter.ts -nav_order: 6 +nav_order: 7 parent: Modules --- diff --git a/docs/modules/Reporter.ts.md b/docs/modules/Reporter.ts.md index a76c04874..29f039bdc 100644 --- a/docs/modules/Reporter.ts.md +++ b/docs/modules/Reporter.ts.md @@ -1,6 +1,6 @@ --- title: Reporter.ts -nav_order: 7 +nav_order: 8 parent: Modules --- diff --git a/docs/modules/Schema.ts.md b/docs/modules/Schema.ts.md index 6500e9c89..634ad876e 100644 --- a/docs/modules/Schema.ts.md +++ b/docs/modules/Schema.ts.md @@ -1,6 +1,6 @@ --- title: Schema.ts -nav_order: 8 +nav_order: 9 parent: Modules --- diff --git a/docs/modules/Schemable.ts.md b/docs/modules/Schemable.ts.md index 2c38c3deb..afd53b2ed 100644 --- a/docs/modules/Schemable.ts.md +++ b/docs/modules/Schemable.ts.md @@ -1,6 +1,6 @@ --- title: Schemable.ts -nav_order: 9 +nav_order: 10 parent: Modules --- diff --git a/docs/modules/Tree.ts.md b/docs/modules/Tree.ts.md index 8a97417e3..4b880a000 100644 --- a/docs/modules/Tree.ts.md +++ b/docs/modules/Tree.ts.md @@ -1,6 +1,6 @@ --- title: Tree.ts -nav_order: 10 +nav_order: 11 parent: Modules --- diff --git a/docs/modules/index.ts.md b/docs/modules/index.ts.md index 3bcd78e48..f3f3a66c9 100644 --- a/docs/modules/index.ts.md +++ b/docs/modules/index.ts.md @@ -1,6 +1,6 @@ --- title: index.ts -nav_order: 5 +nav_order: 6 parent: Modules --- diff --git a/package.json b/package.json index 287dbef9d..0fe71f424 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "perf": "ts-node perf/index", "dtslint": "dtslint dtslint", "mocha": "TS_NODE_CACHE=false mocha -r ts-node/register test/*.ts", - "doctoc": "doctoc README.md Type.md Decoder.md Encoder.md Codec.md", + "doctoc": "doctoc README.md Type.md Decoder.md Encoder.md Codec.md Eq.md", "docs": "docs-ts", "import-path-rewrite": "import-path-rewrite" }, diff --git a/src/Eq.ts b/src/Eq.ts new file mode 100644 index 000000000..e29ad77e5 --- /dev/null +++ b/src/Eq.ts @@ -0,0 +1,163 @@ +/** + * @since 2.2.2 + */ +import * as A from 'fp-ts/lib/Array' +import * as E from 'fp-ts/lib/Eq' +import * as R from 'fp-ts/lib/Record' +import * as S from './Schemable' +import Eq = E.Eq + +// ------------------------------------------------------------------------------------- +// primitives +// ------------------------------------------------------------------------------------- + +/** + * @since 2.2.2 + */ +export const string: Eq = E.eqString + +/** + * @since 2.2.2 + */ +export const number: Eq = E.eqNumber + +/** + * @since 2.2.2 + */ +export const boolean: Eq = E.eqBoolean + +/** + * @since 2.2.2 + */ +export const UnknownArray: Eq> = E.fromEquals((x, y) => x.length === y.length) + +/** + * @since 2.2.2 + */ +export const UnknownRecord: Eq> = E.fromEquals((x, y) => { + for (const k in x) { + if (!(k in y)) { + return false + } + } + for (const k in y) { + if (!(k in x)) { + return false + } + } + return true +}) + +// ------------------------------------------------------------------------------------- +// combinators +// ------------------------------------------------------------------------------------- + +/** + * @since 2.2.2 + */ +export function nullable(or: Eq): Eq { + return { + equals: (x, y) => (x === null || y === null ? x === y : or.equals(x, y)) + } +} + +/** + * @since 2.2.2 + */ +export const type: (eqs: { [K in keyof A]: Eq }) => Eq = E.getStructEq + +/** + * @since 2.2.2 + */ +export function partial(properties: { [K in keyof A]: Eq }): Eq> { + return { + equals: (x, y) => { + for (const k in properties) { + const xk = x[k] + const yk = y[k] + if (!(xk === undefined || yk === undefined ? xk === yk : properties[k].equals(xk!, yk!))) { + return false + } + } + return true + } + } +} + +/** + * @since 2.2.2 + */ +export const record: (codomain: Eq) => Eq> = R.getEq + +/** + * @since 2.2.2 + */ +export const array: (eq: Eq) => Eq> = A.getEq + +/** + * @since 2.2.2 + */ +export const tuple: >( + ...components: { [K in keyof A]: Eq } +) => Eq = E.getTupleEq as any + +/** + * @since 2.2.2 + */ +export function intersection(left: Eq, right: Eq): Eq { + return { + equals: (x, y) => left.equals(x, y) && right.equals(x, y) + } +} + +/** + * @since 2.2.2 + */ +export function sum( + tag: T +): (members: { [K in keyof A]: Eq> }) => Eq { + return (members: Record>) => { + return { + equals: (x: Record, y: Record) => { + const vx = x[tag] + const vy = y[tag] + if (vx !== vy) { + return false + } + return members[vx].equals(x, y) + } + } + } +} + +/** + * @since 2.2.2 + */ +export function lazy(f: () => Eq): Eq { + const get = S.memoize>(f) + return { + equals: (x, y) => get().equals(x, y) + } +} + +/** + * @since 2.2.2 + */ +export const eq: typeof E.eq & S.Schemable = { + ...E.eq, + literal: () => E.eqStrict, + string, + number, + boolean, + UnknownArray, + UnknownRecord, + nullable, + type, + partial, + record, + array, + tuple, + intersection, + sum, + lazy: (_, f) => lazy(f) +} diff --git a/test/Eq.ts b/test/Eq.ts new file mode 100644 index 000000000..2c9dbe0c9 --- /dev/null +++ b/test/Eq.ts @@ -0,0 +1,81 @@ +import * as assert from 'assert' +import * as E from '../src/Eq' +import { Eq } from 'fp-ts/lib/Eq' + +describe('Eq', () => { + it('literal', () => { + const eq = E.eq.literal('a', null) + assert.deepStrictEqual(eq.equals('a', 'a'), true) + assert.deepStrictEqual(eq.equals(null, null), true) + assert.deepStrictEqual(eq.equals('a', null), false) + }) + + it('UnknownArray', () => { + const eq = E.UnknownArray + assert.deepStrictEqual(eq.equals(['a'], ['a']), true) + assert.deepStrictEqual(eq.equals(['a'], ['b']), true) + assert.deepStrictEqual(eq.equals(['a'], ['a', 'b']), false) + }) + + it('UnknownRecord', () => { + const eq = E.UnknownRecord + assert.deepStrictEqual(eq.equals({}, {}), true) + assert.deepStrictEqual(eq.equals({ a: 1 }, { a: 1 }), true) + assert.deepStrictEqual(eq.equals({ a: 1 }, { a: 1, b: true }), false) + assert.deepStrictEqual(eq.equals({ a: 1, b: true }, { a: 1 }), false) + }) + + it('partial', () => { + const eq = E.partial({ a: E.number }) + assert.deepStrictEqual(eq.equals({ a: 1 }, { a: 1 }), true) + assert.deepStrictEqual(eq.equals({ a: undefined }, { a: undefined }), true) + assert.deepStrictEqual(eq.equals({}, { a: undefined }), true) + assert.deepStrictEqual(eq.equals({}, {}), true) + assert.deepStrictEqual(eq.equals({ a: 1 }, {}), false) + }) + + it('tuple', () => { + const eq = E.tuple(E.string, E.number) + assert.deepStrictEqual(eq.equals(['a', 1], ['a', 1]), true) + assert.deepStrictEqual(eq.equals(['a', 1], ['b', 1]), false) + assert.deepStrictEqual(eq.equals(['a', 1], ['a', 2]), false) + }) + + it('intersection', () => { + const eq = E.intersection(E.type({ a: E.string }), E.type({ b: E.number })) + assert.deepStrictEqual(eq.equals({ a: 'a', b: 1 }, { a: 'a', b: 1 }), true) + assert.deepStrictEqual(eq.equals({ a: 'a', b: 1 }, { a: 'c', b: 1 }), false) + assert.deepStrictEqual(eq.equals({ a: 'a', b: 1 }, { a: 'a', b: 2 }), false) + }) + + it('lazy', () => { + interface A { + a: number + b: Array + } + + const eq: Eq = E.eq.lazy('A', () => + E.type({ + a: E.number, + b: E.array(eq) + }) + ) + assert.strictEqual(eq.equals({ a: 1, b: [] }, { a: 1, b: [] }), true) + assert.strictEqual(eq.equals({ a: 1, b: [{ a: 2, b: [] }] }, { a: 1, b: [{ a: 2, b: [] }] }), true) + assert.strictEqual(eq.equals({ a: 1, b: [] }, { a: 2, b: [] }), false) + assert.strictEqual(eq.equals({ a: 1, b: [{ a: 2, b: [] }] }, { a: 1, b: [{ a: 3, b: [] }] }), false) + }) + + it('sum', () => { + const sum = E.sum('_tag') + const eq = sum({ + A: E.type({ _tag: E.eq.literal('A'), a: E.string }), + B: E.type({ _tag: E.eq.literal('B'), b: E.number }) + }) + assert.strictEqual(eq.equals({ _tag: 'A', a: 'a' }, { _tag: 'A', a: 'a' }), true) + assert.strictEqual(eq.equals({ _tag: 'B', b: 1 }, { _tag: 'B', b: 1 }), true) + assert.strictEqual(eq.equals({ _tag: 'A', a: 'a' }, { _tag: 'B', b: 1 }), false) + assert.strictEqual(eq.equals({ _tag: 'A', a: 'a' }, { _tag: 'A', a: 'b' }), false) + assert.strictEqual(eq.equals({ _tag: 'B', b: 1 }, { _tag: 'B', b: 2 }), false) + }) +}) diff --git a/test/Schema.ts b/test/Schema.ts index 8724abbfc..2db29baf3 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -4,6 +4,7 @@ import * as G from '../src/Guard' import * as D from '../src/Decoder' import * as E from '../src/Encoder' import * as C from '../src/Codec' +import * as Eq from '../src/Eq' import * as A from './Arbitrary' import * as fc from 'fast-check' import * as assert from 'assert' @@ -21,14 +22,19 @@ function check(schema: Schema): void { const arb = schema(A.arbitrary) const codec = schema(C.codec) const guard = schema(G.guard) - // decoders and guards should be aligned - fc.assert(fc.property(arb, (a) => guard.is(a) && isRight(codec.decode(a)))) + const eq = schema(Eq.eq) + // decoders, guards and eqs should be aligned + fc.assert(fc.property(arb, (a) => isRight(codec.decode(a)) && guard.is(a) && eq.equals(a, a))) // laws fc.assert(fc.property(arb, (a) => isRight(codec.decode(codec.encode(a))))) fc.assert( fc.property(arb, (u) => { const a = codec.decode(u) - return isRight(a) && isDeepStrictEqual(codec.encode(a.right), u) + if (isRight(a)) { + const o = a.right + return isDeepStrictEqual(codec.encode(o), u) && eq.equals(o, u) + } + return false }) ) }