From feea68b4d499a5fd4361cc424cf45b76c0e7f3d7 Mon Sep 17 00:00:00 2001 From: Alex Galays Date: Sat, 25 Nov 2017 17:27:56 +0100 Subject: [PATCH] feat: Allow the updating of an union property by using a type guard (https://github.com/AlexGalays/immupdate/issues/12) --- README.md | 21 +++++++++++++ immupdate.ts | 83 +++++++++++++++++++++++++++++++++------------------- package.json | 2 +- test/test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7c18593..4cdb255 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This library only does simple updates (e.g setting, modifying or deleting a valu * [Update an Array item](#update-array-item) * [Update a nested property using its current value](#update-nested-property-modify) * [Update a nested property on a nullable path](#update-nested-nullable-property) + * [Update a nested union property](#update-nested-union-property) * [Reuse a nested updater](#reuse-nested-updater) @@ -246,6 +247,26 @@ deepUpdate({}) .set('en') ``` + +## Update a nested union property + +```ts +import { deepUpdate } from 'immupdate' + +type A = { type: 'a', data: string } +type B = { type: 'b', data: number } +type Container = { aOrB: A | B } +const isA = (u: A | B): u is A => u.type === 'a' + +const container = { aOrB: { type: 'a', data: 'aa' } } + +deepUpdate(container) + .at('aOrB') + .abortIfNot(isA) + .at('data') + .set('bb') +``` + ## Reuse a nested updater diff --git a/immupdate.ts b/immupdate.ts index 4ec2e27..8f795de 100644 --- a/immupdate.ts +++ b/immupdate.ts @@ -36,81 +36,101 @@ export const DELETE = {} as any as undefined export type ObjectLiteral = object & { reduceRight?: 'nope' } -export interface AtUpdater { - __T: [R, O] // Strengthen structural typing +export interface AtUpdater { + __T: [TARGET, CURRENT] // Strengthen structural typing /** * Selects this Object key for update or further at() chaining */ - at(this: AtUpdater, key: K): Updater + at(this: AtUpdater, key: K): Updater /** * Selects an Array index for update or further at() chaining */ - at(this: AtUpdater, index: number): Updater + at(this: AtUpdater, index: number): Updater } // The at interface carrying a pre-bound value -export interface BoundAtUpdater { - __T: [R, O] +export interface BoundAtUpdater { + __T: [TARGET, CURRENT] /** * Selects this Object key for update or further at() chaining */ - at(this: BoundAtUpdater, key: K): BoundUpdater + at(this: BoundAtUpdater, key: K): BoundUpdater /** * Selects an Array index for update or further at() chaining */ - at(this: BoundAtUpdater, index: number): BoundUpdater + at(this: BoundAtUpdater, index: number): BoundUpdater } -export interface Updater extends AtUpdater { - __T: [R, O] +export interface Updater extends AtUpdater { + __T: [TARGET, CURRENT] /** * Sets the value at the currently selected path. */ - set(value: O): (target: R) => R + set(value: CURRENT): (target: TARGET) => TARGET /** * Modifies the value at the specified path. The current value is passed. */ - modify(modifier: (value: O) => O): (target: R) => R + modify(modifier: (value: CURRENT) => CURRENT): (target: TARGET) => TARGET /** * Makes the previous nullable chain level 'safe' by using a default value */ - withDefault(this: Updater, defaultValue: C): Updater + withDefault(this: Updater, defaultValue: C): Updater /** * Aborts the whole update operation if the previous chain level is null or undefined. */ - abortIfUndef(this: Updater): Updater + abortIfUndef(this: Updater): Updater + + /** + * Aborts the whole update operation if the previous chain level doesn't verify a type guard + */ + abortIfNot(predicate: (value: CURRENT) => value is C): Updater + + /** + * Aborts the whole update operation if the previous chain level doesn't verify a predicate + */ + abortIfNot(predicate: (value: CURRENT) => boolean): Updater } -export interface BoundUpdater extends BoundAtUpdater { - __T: [R, O] +export interface BoundUpdater extends BoundAtUpdater { + __T: [TARGET, CURRENT] /** * Sets the value at the currently selected path. */ - set(value: O): R + set(value: CURRENT): TARGET /** * Modifies the value at the specified path. The current value is passed. */ - modify(modifier: (value: O) => O): R + modify(modifier: (value: CURRENT) => CURRENT): TARGET /** * Makes the previous nullable chain level 'safe' by using a default value */ - withDefault(this: BoundUpdater, defaultValue: C): BoundUpdater + withDefault(this: BoundUpdater, defaultValue: C): BoundUpdater /** * Aborts the whole update operation if the previous chain level is null or undefined. */ - abortIfUndef(this: BoundUpdater): BoundUpdater + abortIfUndef(this: BoundUpdater): BoundUpdater + + /** + * Aborts the whole update operation if the previous chain level doesn't verify a type guard + */ + abortIfNot(predicate: (value: CURRENT) => value is C): BoundUpdater + + /** + * Aborts the whole update operation if the previous chain level doesn't verify a predicate + */ + abortIfNot(predicate: (value: CURRENT) => boolean): BoundUpdater } @@ -131,12 +151,13 @@ interface WithDefault { parent: any } -interface AbortIfUndef { - type: 'abortIfUndef' +interface AbortIfNot { + type: 'abortIfNot' + predicate: any parent: any } -type UpdaterData = Root | At | WithDefault | AbortIfUndef +type UpdaterData = Root | At | WithDefault | AbortIfNot @@ -191,8 +212,12 @@ class _Updater { return new _Updater({ type: 'withDefault', parent: this, defaultValue: value }) } + abortIfNot(predicate: any): any { + return new _Updater({ type: 'abortIfNot', parent: this, predicate }) + } + abortIfUndef(): any { - return new _Updater({ type: 'abortIfUndef', parent: this }) + return this.abortIfNot((value: any) => value !== undefined) } findBoundTarget() { @@ -227,13 +252,11 @@ class _Updater { return { host: newHost, field: newField } } - const value = previousHost[field] - - if (this.data.type === 'abortIfUndef' && value === undefined) { + if (this.data.type === 'abortIfNot' && this.data.predicate(host) === false) { return { host, field, aborted: true } } - if (this.data.type === 'withDefault' && value === undefined) { + if (this.data.type === 'withDefault' && previousHost[field] === undefined) { const nextValue = this.data.defaultValue const newHost = isLast ? previousHost : nextValue previousHost[field] = nextValue @@ -290,8 +313,8 @@ function clone(obj: any): any { return cloned } -export function deepUpdate(target: O): BoundAtUpdater -export function deepUpdate(): AtUpdater +export function deepUpdate(target: TARGET): BoundAtUpdater +export function deepUpdate(): AtUpdater export function deepUpdate(target?: any): any { return new _Updater({ type: 'root', boundTarget: target }) } \ No newline at end of file diff --git a/package.json b/package.json index 06f1eda..7fb27a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "immupdate", - "version": "1.1.6", + "version": "1.1.7", "description": "Immutable update for Objects and Arrays", "license": "MIT", diff --git a/test/test.ts b/test/test.ts index 4f9ea04..95779ea 100644 --- a/test/test.ts +++ b/test/test.ts @@ -658,6 +658,76 @@ describe('immupdate', () => { expect(updated4).toNotBe(obj2) }) + it('can update an union member with an instance of this union', () => { + + type A = { type: 'a', data: number } + type B = { type: 'b', data: string } + type U = A | B + + type Obj = { u: U } + + const obj: Obj = { u: { type: 'a', data: 10 } } + + const updated = deepUpdate(obj) + .at('u') + .set({ type: 'b', data: '11' }) + + expect(updated).toEqual({ + u: { type: 'b', data: '11' } + }) + }) + + it('can abort if a simple condition is not met', () => { + type Obj = { a?: { version: number, data: string } } + + const obj: Obj = { a: { version: 0, data: '001' } } + + const updated = deepUpdate(obj) + .at('a') + .abortIfUndef() + .abortIfNot(a => a.version === 0) + .at('data') + .set('002') + + expect(updated).toNotBe(obj) + expect(updated).toEqual({ + a: { version: 0, data: '002' } + }) + + const obj2: Obj = { a: { version: 1, data: '001' } } + + const updated2 = deepUpdate(obj2) + .at('a') + .abortIfUndef() + .abortIfNot(a => a.version === 0) + .set({ version: 2, data: 'zzz' }) + + expect(updated2).toBe(obj2) + }) + + it('can abort if a type guard is not passed', () => { + + type A = { type: 'a', data: number } + type B = { type: 'b', data: string } + type U = A | B + + const isA = (u: U): u is A => u.type === 'a' + + type Obj = { u: U } + + const obj: Obj = { u: { type: 'a', data: 10 } } + + const updated = deepUpdate(obj) + .at('u') + .abortIfNot(isA) + .at('data') + .set(20) + + expect(updated).toEqual({ + u: { type: 'a', data: 20 } + }) + }) + }) }) \ No newline at end of file