diff --git a/README.md b/README.md index d2c2f6c..1c2686d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The Either type encapsulates the idea of a calculation that might have failed. An Either value can either be `Right` some value or `Left` some error. ```ts -type Either = Right | Left; +type Either = Right | Left; ``` ## Install @@ -20,24 +20,68 @@ npm install ts.data.either --save ## Example ```ts -import { right, map, withDefault } from 'ts.data.either'; +import {} from 'ts.data.either'; -const head = (arr: number[]): number => { - if (arr.length === 0) { - throw new Error('Array is empty'); - } - return arr.slice(0, 1)[0]; +type Band = { + artist: string; + bio: string; +}; +const bandsJsonWithContent: { [key: string]: string } = { + 'bands.json': ` + [ + {"artist": "Clark", "bio": "Clark bio..."}, + {"artist": "Plaid", "bio": "Plaid bio..."} + ] + ` }; +const bandsJsonWithoutContent: { [key: string]: string } = { + 'bands.json': '' +}; +const generateExample = ( + filenameToRead: string, + folder: { [key: string]: string } +) => { + const readFile = (filename: string): Either => { + if (folder.hasOwnProperty(filename)) { + return right(folder[filename]); + } + return left(new Error(`File ${filename} doesn't exist`)); + }; + const bandsJson = readFile(filenameToRead); + const bands = map(json => JSON.parse(json) as Band[], bandsJson); + const bandNames = map(bands => bands.map(band => band.artist), bands); + return bandNames; +}; + +// Should compute band names properly +let bandNames = generateExample('bands.json', bandsJsonWithContent); +caseOf( + { + Left: err => err.message, + Right: names => names + }, + bandNames +).then(names => console.log(names)); // ['Clark', 'Plaid'] -// successful operation -let wrappedValue = map(head, right([99, 109, 22, 65])); // Right(100) -let num = withDefault(wrappedValue, 0); // unwrap the value from Either -console.log(num); // 99 +// Should fail becasue file non-existing.json doesn't exist +bandNames = generateExample('non-existing.json', bandsJsonWithContent); +caseOf( + { + Left: err => err.message, + Right: names => names + }, + bandNames +).catch(err => console.log(err)); // File non-existing.json doesn't exist -// failing operation -wrappedValue = map(head, right([])); // Left(Error('Array is empty')) -num = withDefault(wrappedValue, 0); // unwrap the value from Either -console.log(num); // 0 +// should fail becasue file content is not valid Json +bandNames = generateExample('bands.json', bandsJsonWithoutContent); +caseOf( + { + Left: err => err.message, + Right: names => names + }, + bandNames +).catch(err => console.log(err)); // Unexpected end of JSON input ``` ## Api @@ -46,7 +90,7 @@ _(Inspired by elm-lang)_ ### right -`right(value: T): Right` +`right(value: T): Either` Wraps a value in an instance of `Right`. @@ -56,7 +100,7 @@ right(5); // Right(5) ### left -`left(error: E): Left` +`left(error: Error): Either` Creates an instance of `Left`. @@ -66,17 +110,17 @@ left('Something bad happened'); // Left('Something bad happened') ### isRight -`isRight(value: Either)` +`isRight(value: Either): boolean` Returns true if a value is an instance of `Right`. ```ts -isRight(left('error')); // false +isRight(left(new Error('Wrong!'))); // false ``` ### isLeft -`isLeft(value: Either)` +`isLeft(value: Either): boolean` Returns true if a value is an instance of `Left`. @@ -86,46 +130,46 @@ isLeft(right(5)); // false ### withDefault -`withDefault(value: Either, defaultValue: T): T` +`withDefault(value: Either, defaultValue: T): T` If `value` is an instance of `Right` it returns its wrapped value, if it's an instance of `Left` it returns the `defaultValue`. ```ts withDefault(right(5), 0); // 5 -withDefault(left('error'), 0); // 0 +withDefault(left(new Error('Wrong!')), 0); // 0 ``` ### caseOf -`caseOf = (caseof: {Right: (v: A) => B; Left: (v: E) => B;}, value: Either ): B` +`(caseof: {Right: (v: A) => B; Left: (v: Error) => any;}, value: Either): Promise` -Run different computations depending on whether an `Either` is `Right` or `Left`. +Run different computations depending on whether an `Either` is `Right` or `Left` and returns a `Promise` ```ts caseOf( { - Left: () => 'zzz', + Left: err => `Error: ${err.message}`, Right: n => `Launch ${n} missiles` }, - right(5) -); // 'Launch 5 missiles' + right('5') +).then(res => console.log(res)); // 'Launch 5 missiles' ``` ### map -`map(f: (a: A) => B, value: Either): Either` +`(f: (a: A) => B, value: Either): Either` Transforms an `Either` value with a given function. ```ts const add1 = (n: number) => n + 1; map(add1, right(4)); // Right(5) -map(add1, left('errors')); // Left('errors') +map(add1, left(new Error('Something bad happened'))); // Left('Something bad happened') ``` ### andThen -`andThen(f: (a: A) => Either, value: Either): Either` +`andThen(f: (a: A) => Either, value: Either): Either` Chains together computations that may fail. @@ -136,7 +180,7 @@ const removeFirstElement = (arr: T[]): T[] => { } return arr.slice(1); }; -const removeFirstLifted = (arr: T[]): Either => { +const safeRemoveFirst = (arr: T[]): Either => { try { return right(removeFirstElement(arr)); } catch (error) { @@ -144,8 +188,9 @@ const removeFirstLifted = (arr: T[]): Either => { } }; const result = andThen( - arr => andThen(arr2 => removeFirstLifted(arr2), removeFirstLifted(arr)), - removeFirstLifted(['a', 'b']) + arr => + andThen(arr2 => safeRemoveFirstElement(arr2), safeRemoveFirstElement(arr)), + safeRemoveFirstElement(['a', 'b']) ); withDefault(result, 'default val'); // 'default val' ``` diff --git a/package.json b/package.json index 2013bb5..5d27eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts.data.either", - "version": "0.9.2", + "version": "1.0.0", "description": "A Typescript implementation of the Either data type", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,8 +27,10 @@ "homepage": "https://github.com/joanllenas/ts.data.either#readme", "devDependencies": { "@types/chai": "^4.2.5", + "@types/chai-as-promised": "^7.1.2", "@types/mocha": "^5.2.7", "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "mocha": "^6.2.2", "prettier": "^1.19.1", "ts-node": "^8.5.2", diff --git a/src/either.spec.ts b/src/either.spec.ts index 318513d..dc675a2 100644 --- a/src/either.spec.ts +++ b/src/either.spec.ts @@ -10,12 +10,14 @@ import { caseOf } from './either'; -import * as mocha from 'mocha'; import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +chai.use(chaiAsPromised); const expect = chai.expect; -const createError = (value: any) => `Value "${value}" is not an Either type`; +const createError = (value: T) => `Value "${value}" is not an Either type`; +const anError = () => new Error('Something is wrong'); const add1 = (n: number) => n + 1; const removeFirstElement = (arr: T[]): T[] => { if (arr.length === 0) { @@ -27,13 +29,14 @@ const removeFirstElement = (arr: T[]): T[] => { describe('Either', () => { describe('isRight', () => { it('should return false when Left is provided', () => { - expect(isRight(left(''))).to.be.false; + expect(isRight(left(anError()))).to.be.false; }); it('should return true when Right is provided', () => { expect(isRight(right('hola'))).to.be.true; }); it('should throw when a non Either type is provided', () => { - expect(() => isRight([1, 2, 3] as any)).to.throw(createError([1, 2, 3])); + const nonEither: any = [1, 2, 3]; + expect(() => isRight(nonEither)).to.throw(createError(nonEither)); }); it('should throw when null or undefined is provided', () => { expect(() => isRight(null)).to.throw(createError(null)); @@ -46,10 +49,11 @@ describe('Either', () => { expect(isLeft(right('hola'))).to.be.false; }); it('should return true when Left is provided', () => { - expect(isLeft(left('hola'))).to.be.true; + expect(isLeft(left(anError()))).to.be.true; }); it('should throw when a non Either type is provided', () => { - expect(() => isLeft('lala' as any)).to.throw(createError('lala')); + const nonEither: any = [1, 2, 3]; + expect(() => isLeft(nonEither)).to.throw(createError(nonEither)); }); it('should throw when null or undefined is provided', () => { expect(() => isLeft(null)).to.throw(createError(null)); @@ -62,10 +66,11 @@ describe('Either', () => { expect(withDefault(right(6), 0)).to.equal(6); }); it('should return the default value when Left is provided', () => { - expect(withDefault(left('err'), 0)).to.equal(0); + expect(withDefault(left(anError()), 0)).to.equal(0); }); it('should throw when a non Either type is provided', () => { - expect(() => withDefault('hola' as any, 2)).to.throw(createError('hola')); + const nonEither: any = [1, 2, 3]; + expect(() => withDefault(nonEither, 2)).to.throw(createError(nonEither)); }); it('should throw when null or undefined is provided', () => { expect(() => withDefault(null, 1)).to.throw(createError(null)); @@ -80,7 +85,7 @@ describe('Either', () => { expect(isRight(five)).to.be.true; }); it('should return Left when mapping over Left', () => { - const result = map(add1, left('err')); + const result = map(add1, left(anError())); expect(isLeft(result)).to.be.true; }); it('should return 5 when adding 1 to Right(4)', () => { @@ -97,7 +102,6 @@ describe('Either', () => { it('should return a Left when mapping produces an exception', () => { const list = map(removeFirstElement, right([])); expect(isLeft(list)).to.be.true; - expect(withDefault(list, ['default', 'val'])).to.eql(['default', 'val']); }); it('should throw when mapping over null, undefined or any non Either type', () => { expect(() => map(add1, null)).to.throw(createError(null)); @@ -109,7 +113,7 @@ describe('Either', () => { }); describe('andThen', () => { - const removeFirstLifted = (arr: T[]): Either => { + const safeRemoveFirst = (arr: T[]): Either => { try { return right(removeFirstElement(arr)); } catch (error) { @@ -118,15 +122,15 @@ describe('Either', () => { }; it('should perform chained Right transformations', () => { const result = andThen( - arr => removeFirstLifted(arr), - removeFirstLifted(['a', 'b', 'c']) + arr => safeRemoveFirst(arr), + safeRemoveFirst(['a', 'b', 'c']) ); expect(withDefault(result, ['default val'])).to.eql(['c']); }); it('should perform chained Right transformations and fail with Left', () => { const result = andThen( - arr => andThen(arr2 => removeFirstLifted(arr2), removeFirstLifted(arr)), - removeFirstLifted(['a', 'b']) + arr => andThen(arr2 => safeRemoveFirst(arr2), safeRemoveFirst(arr)), + safeRemoveFirst(['a', 'b']) ); expect(withDefault(result, ['default val'])).to.eql(['default val']); }); @@ -143,38 +147,102 @@ describe('Either', () => { describe('caseOf', () => { it('should Launch 5 missiles', () => { - const result = caseOf( - { - Left: err => `Error: ${err}`, - Right: n => `Launch ${n} missiles` - }, - right('5') - ); - expect(result).to.equal('Launch 5 missiles'); - }); - it('should zzz', () => { - const result = caseOf( - { - Left: () => 'zzz', - Right: n => `Launch ${n} missiles` - }, - left('zzz') - ); - expect(result).to.equal('zzz'); + return expect( + caseOf( + { + Left: err => `Error: ${err.message}`, + Right: n => `Launch ${n} missiles` + }, + right('5') + ) + ).to.eventually.equal('Launch 5 missiles'); + }); + it('should error', () => { + return expect( + caseOf( + { + Left: err => `Error: ${err.message}`, + Right: n => `Launch ${n} missiles` + }, + left(anError()) + ) + ).to.be.rejectedWith('Error: Something is wrong'); }); }); describe('examples', () => { - it('should work ', () => { - const head = (arr: number[]): number => { - if (arr.length === 0) { - throw new Error('Array is empty'); + type Band = { + artist: string; + bio: string; + }; + const bandsJsonWithContent: { [key: string]: string } = { + 'bands.json': ` + [ + {"artist": "Clark", "bio": "Clark bio..."}, + {"artist": "Plaid", "bio": "Plaid bio..."} + ] + ` + }; + const bandsJsonWithoutContent: { [key: string]: string } = { + 'bands.json': '' + }; + + const generateExample = ( + filenameToRead: string, + folder: { [key: string]: string } + ) => { + const readFile = (filename: string): Either => { + if (folder.hasOwnProperty(filename)) { + return right(folder[filename]); } - return arr.slice(0, 1)[0]; + return left(new Error(`File ${filename} doesn't exist`)); }; - const num = map(head, right([99, 109, 22, 65])); - expect(withDefault(map(add1, num), 0)).to.equal(100); - expect(withDefault(andThen(n => right(add1(n)), num), 0)).to.equal(100); + const bandsJson = readFile(filenameToRead); + const bands = map(json => JSON.parse(json) as Band[], bandsJson); + const bandNames = map(bands => bands.map(band => band.artist), bands); + return bandNames; + }; + + it('should be all right', () => { + const bandNames = generateExample('bands.json', bandsJsonWithContent); + return expect( + caseOf( + { + Left: err => err.message, + Right: names => names + }, + bandNames + ) + ).to.eventually.deep.equal(['Clark', 'Plaid']); + }); + + it(`should fail becasue File xyz doesn't exist`, () => { + const bandNames = generateExample( + 'non-existing.json', + bandsJsonWithContent + ); + return expect( + caseOf( + { + Left: err => err.message, + Right: names => names + }, + bandNames + ) + ).to.be.rejectedWith(`File non-existing.json doesn't exist`); + }); + + it(`should fail becasue File content is not valid Json`, () => { + const bandNames = generateExample('bands.json', bandsJsonWithoutContent); + return expect( + caseOf( + { + Left: err => err.message, + Right: names => names + }, + bandNames + ) + ).to.be.rejectedWith(`Unexpected end of JSON input`); }); }); }); diff --git a/src/either.ts b/src/either.ts index ee5d06e..231bb23 100644 --- a/src/either.ts +++ b/src/either.ts @@ -1,39 +1,39 @@ -export class Right { +class Right { private _tag = 'right'; constructor(readonly _value: T) {} } -export class Left { +class Left { private _tag = 'left'; - constructor(readonly _error: E) {} + constructor(readonly _error: Error) {} } -export type Either = Right | Left; +export type Either = Right | Left; -const assertIsEither = function(value: Either) { +const assertIsEither = function(value: Either) { if (!(value instanceof Right || value instanceof Left)) { throw new Error(`Value "${value}" is not an Either type`); } }; -export const right = (value: T): Right => { +export const right = (value: T): Either => { return new Right(value); }; -export const left = (error: E): Left => { +export const left = (error: Error): Either => { return new Left(error); }; -export const isRight = (value: Either) => { +export const isRight = (value: Either): boolean => { assertIsEither(value); return value instanceof Right; }; -export const isLeft = (value: Either) => { +export const isLeft = (value: Either): boolean => { assertIsEither(value); return value instanceof Left; }; -export const withDefault = (value: Either, defaultValue: T): T => { +export const withDefault = (value: Either, defaultValue: T): T => { switch (isLeft(value)) { case true: return defaultValue; @@ -42,13 +42,10 @@ export const withDefault = (value: Either, defaultValue: T): T => { } }; -export const map = ( - f: (a: A) => B, - value: Either -): Either => { +export const map = (f: (a: A) => B, value: Either): Either => { switch (isLeft(value)) { case true: - return value as Left; + return value as Either; case false: try { return right(f((value as Right)._value)); @@ -58,29 +55,29 @@ export const map = ( } }; -export const andThen = ( - f: (a: A) => Either, - value: Either -): Either => { +export const andThen = ( + f: (a: A) => Either, + value: Either +): Either => { switch (isLeft(value)) { case true: - return value as Left; + return value as Left; case false: return f((value as Right)._value); } }; -export const caseOf = ( +export const caseOf = ( caseof: { Right: (v: A) => B; - Left: (v: E) => B; + Left: (v: Error) => any; }, - value: Either -): B => { + value: Either +): Promise => { switch (isLeft(value)) { case true: - return caseof.Left((value as Left)._error); + return Promise.reject(caseof.Left((value as Left)._error)); case false: - return caseof.Right((value as Right)._value); + return Promise.resolve(caseof.Right((value as Right)._value)); } }; diff --git a/src/index.ts b/src/index.ts index 5819637..4a1e6eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ export { - Right, - Left, Either, left, right,