├── .prettierrc.json ├── .gitignore ├── tsconfig.json ├── tsconfig.build.json ├── jest.config.js ├── .eslintrc.js ├── LICENSE.md ├── rollup.config.mjs ├── package.json ├── src ├── index.ts └── interfaces.ts ├── README.md └── test └── index.spec.ts /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | 4 | coverage 5 | dist 6 | 7 | node_modules 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["src/**/*", "test/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "./lib", 7 | "strict": true, 8 | "exactOptionalPropertyTypes": true, 9 | "target": "ES2018" 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | transform: { 4 | "^.+\\.ts$": [ 5 | "ts-jest", 6 | { 7 | diagnostics: { 8 | ignoreCodes: [ 9 | 151001 10 | ] 11 | } 12 | }, 13 | ] 14 | }, 15 | verbose: true, 16 | collectCoverageFrom: [ 17 | "src/**", 18 | "!src/types/**" 19 | ], 20 | coverageReporters: [ 21 | "html", 22 | "text" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: [ 5 | "@typescript-eslint" 6 | ], 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" 12 | ], 13 | rules: { 14 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_+$" }], 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "@typescript-eslint/no-empty-function": "off", 17 | "prefer-const": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present Robert Rinaldo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import terser from "@rollup/plugin-terser" 2 | import typescript from "rollup-plugin-typescript2" 3 | import pkg from './package.json' assert { type: 'json' }; 4 | 5 | export default [ 6 | { 7 | input: "src/index.ts", 8 | output: [ 9 | { 10 | file: pkg.browser, 11 | format: "umd", 12 | name: "evolve-ts", 13 | }, 14 | { 15 | file: pkg.main, 16 | format: "cjs", 17 | }, 18 | { 19 | file: pkg.module, 20 | format: "es", 21 | }, 22 | ], 23 | plugins: [typescript({ tsconfig: "tsconfig.build.json" })], 24 | }, 25 | { 26 | input: "src/index.ts", 27 | output: [ 28 | { 29 | file: `dist/${pkg.name}.min.mjs`, 30 | format: "es", 31 | }, 32 | { 33 | file: pkg.unpkg, 34 | format: "umd", 35 | name: "evolve-ts", 36 | }, 37 | ], 38 | plugins: [typescript({ tsconfig: "tsconfig.build.json" }), terser()], 39 | }, 40 | ] 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evolve-ts", 3 | "version": "2.2.0", 4 | "description": "Immutably update nested objects with patches containing values or functions to update values", 5 | "author": "Robert Rinaldo", 6 | "license": "MIT", 7 | "repository": "github:Rinaldo/evolve-ts", 8 | "bugs": "https://github.com/Rinaldo/evolve-ts/issues", 9 | "keywords": [ 10 | "merge", 11 | "evolve", 12 | "immutable", 13 | "fp" 14 | ], 15 | "browser": "dist/evolve-ts.umd.js", 16 | "main": "dist/evolve-ts.cjs.js", 17 | "module": "dist/evolve-ts.esm.js", 18 | "types": "dist/index.d.ts", 19 | "unpkg": "dist/evolve-ts.umd.min.js", 20 | "files": [ 21 | "/dist", 22 | "/src" 23 | ], 24 | "scripts": { 25 | "build": "npm run clean && rollup -c", 26 | "clean": "rimraf dist", 27 | "lint": "eslint {src,test}/**/*.ts", 28 | "lint-fix": "eslint {src,test}/**/*.ts --fix", 29 | "prettier": "prettier --check {src,test}/**/*.ts", 30 | "prettier-fix": "prettier --write {src,test}/**/*.ts", 31 | "test": "jest", 32 | "test-watch": "jest --watch", 33 | "full-lint": "npm run lint && npm run prettier", 34 | "full-test": "npm run lint && npm run prettier && npm run test", 35 | "coverage": "jest --collectCoverage" 36 | }, 37 | "devDependencies": { 38 | "@rollup/plugin-terser": "^0.4.0", 39 | "@types/jest": "^29.4.1", 40 | "@typescript-eslint/eslint-plugin": "^5.55.0", 41 | "@typescript-eslint/parser": "^5.55.0", 42 | "eslint": "^8.36.0", 43 | "eslint-config-prettier": "^8.7.0", 44 | "jest": "^29.5.0", 45 | "prettier": "2.8.4", 46 | "rimraf": "^4.4.0", 47 | "rollup": "^3.19.1", 48 | "rollup-plugin-typescript2": "^0.34.1", 49 | "ts-jest": "^29.0.5", 50 | "typescript": "^4.9.5" 51 | }, 52 | "sideEffects": false 53 | } 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Adjust, 3 | Evolve, 4 | Evolve_, 5 | MapArray, 6 | ShallowEvolve, 7 | ShallowAdjust, 8 | ShallowMapArray, 9 | unset, 10 | } from "./interfaces" 11 | export * from "./interfaces" 12 | 13 | const toString = {}.toString 14 | const isObj = (o: any) => toString.call(o) === "[object Object]" 15 | const isFn = (x: any): x is (...args: any[]) => any => 16 | x != unset && typeof x === "function" // this will return false positives for classes other than unset 17 | const curry = 18 | (fn: (...args: any[]) => any) => 19 | (...args: any[]) => 20 | args.length < fn.length 21 | ? (...moreArgs: any[]) => curry(fn)(...args, ...moreArgs) 22 | : fn(...args) 23 | 24 | const baseEvolve = (patch: any, target: any) => { 25 | if (patch && isObj(patch)) { 26 | // shave bytes by reassigning, but not mutating, arguments 27 | target = isObj(target) ? { ...target } : {} 28 | Object.keys(patch).forEach((key) => { 29 | target[key] = baseEvolve(patch[key], target[key]) 30 | if (target[key] == unset) delete target[key] 31 | }) 32 | return target 33 | } else if (isFn(patch)) { 34 | return patch(target) 35 | } else { 36 | return patch 37 | } 38 | } 39 | 40 | /** deeply merges changes from a patch object into a target object, the patch object can contain new values or functions to update values */ 41 | export const evolve = curry(baseEvolve) as Evolve 42 | 43 | /** polymorphic type alias for evolve: deeply merges changes from a patch object into a target object, the patch object can contain new values or functions to update values */ 44 | export const evolve_: Evolve_ = evolve 45 | 46 | const baseShallowEvolve = (patch: any, target: any) => { 47 | // shave bytes by reassigning, but not mutating, arguments 48 | target = { ...target } 49 | Object.keys(patch).forEach((key) => { 50 | const update = patch[key] 51 | target[key] = isFn(update) ? update(target[key]) : update 52 | if (target[key] == unset) delete target[key] 53 | }) 54 | return target 55 | } 56 | 57 | /** merges changes from a patch object into a target object, the patch object can contain new values or functions to update values */ 58 | export const shallowEvolve = curry(baseShallowEvolve) as ShallowEvolve 59 | 60 | const createAdjust = 61 | (ev: any) => (predicateOrIndex: any, updaterOrPatch: any, array: any[]) => { 62 | // shave bytes by reassigning, but not mutating, arguments 63 | if (!isFn(updaterOrPatch)) { 64 | updaterOrPatch = ev(updaterOrPatch) 65 | } 66 | // track if any item was changed 67 | let changed 68 | const updater = (item: any) => ((changed = 1), updaterOrPatch(item)) 69 | if (predicateOrIndex < 0) { 70 | // allow using negative indexes as offsets from end 71 | predicateOrIndex = array.length + predicateOrIndex 72 | } 73 | 74 | const mapped = array.map( 75 | isFn(predicateOrIndex) 76 | ? (item) => (predicateOrIndex(item) ? updater(item) : item) 77 | : (item, i) => (i === predicateOrIndex ? updater(item) : item) 78 | ) 79 | return changed ? mapped : array 80 | } 81 | 82 | /** conditionally maps values in an array with a callback function or patch. Value(s) to map can be specified with an index or predicate function. Negative indexes are treated as offsets from the array length */ 83 | export const adjust: Adjust = curry(createAdjust(evolve)) 84 | 85 | /** conditionally maps values in an array with a callback function or patch. Value(s) to map can be specified with an index or predicate function. Negative indexes are treated as offsets from the array length */ 86 | export const shallowAdjust: ShallowAdjust = curry(createAdjust(shallowEvolve)) 87 | 88 | const createMap = (ev: any) => (updaterOrPatch: any, array: any[]) => 89 | array.map( 90 | isFn(updaterOrPatch) 91 | ? (item: any) => updaterOrPatch(item) // clamp args to 1 92 | : ev(updaterOrPatch) 93 | ) 94 | 95 | /** maps values in an array with a callback function or patch */ 96 | export const map: MapArray = curry(createMap(evolve)) 97 | 98 | /** maps values in an array with a callback function or patch */ 99 | export const shallowMap: ShallowMapArray = curry(createMap(shallowEvolve)) 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # evolve-ts 2 | 3 | `npm install evolve-ts` 4 | 5 | Immutably update nested objects with patches containing values or functions to update values 6 | 7 | - **Simple yet powerful**: simple syntax for performing immutable updates 8 | - **Type-safe**: Robust type checking and type inferences 9 | - **Tiny**: < 1kb gzipped, zero dependencies 10 | 11 | ## Usage 12 | 13 | Let's say we have the following state 14 | ```javascript 15 | import { evolve, unset } from "evolve-ts" 16 | 17 | const state = { 18 | user: { 19 | name: "Alice", 20 | age: 22 21 | } 22 | } 23 | ``` 24 | 25 | We can set a value 26 | ```javascript 27 | evolve({ user: { name: "Bob" } }, state) 28 | // { user: { name: "Bob", age: 22 } } 29 | ``` 30 | 31 | Update a value 32 | ```javascript 33 | evolve({ user: { age: age => age + 1 } }, state) 34 | // { user: { name: "Alice", age: 23 } } 35 | ``` 36 | 37 | Remove a key 38 | ```javascript 39 | evolve({ user: { age: unset } }, state) 40 | // { user: { name: "Alice" } } 41 | ``` 42 | 43 | All together now! 44 | ```javascript 45 | import { evolve, unset } from "evolve-ts" 46 | 47 | const alice = { 48 | name: "Alice", 49 | age: 22, 50 | active: true, 51 | likes: ["wonderland"], 52 | nested: { 53 | foo: true, 54 | bar: true, 55 | } 56 | } 57 | 58 | evolve({ 59 | name: "Bob", // sets the value of name 60 | age: age => age + 1, // updates the value of age 61 | active: unset, // removes the active key 62 | likes: ["building"], // sets the value of likes (only objects are merged) 63 | nested: { 64 | foo: false // sets the value of foo 65 | } 66 | }, alice) 67 | /* 68 | { 69 | name: "Bob", 70 | age: 23, 71 | likes: ["building"], 72 | nested1: { 73 | foo: false, 74 | bar: true, 75 | } 76 | } 77 | */ 78 | ``` 79 | 80 | ### Setting object values directly 81 | 82 | Object values are merged recursively, but return values of functions are set directly. To replace an object value use a `constant` function. 83 | 84 | ```javascript 85 | evolve({ 86 | user: () => ({ 87 | name: "Bob", 88 | age: 33, 89 | }) 90 | }, state) 91 | 92 | // { user: { name: "Bob", age: 33 } } 93 | ``` 94 | 95 | ### Removing object entries 96 | 97 | Entries can be removed by setting the value to `unset` or using an updater function that returns `unset`. 98 | 99 | ```javascript 100 | evolve({ 101 | user: { 102 | name: unset, 103 | age: () => unset 104 | } 105 | }, state) 106 | 107 | // { user: {} } 108 | ``` 109 | 110 | ### Working with Arrays 111 | 112 | evolve-ts provides two functions to update arrays, `map` and `adjust`. 113 | 114 | `map` is a version of the map function that can either take a callback or a patch. The following are all equivalent, they all increment the number of likes for each user in the users array. 115 | ```javascript 116 | import { map } from "evolve-ts" 117 | const inc = n => n + 1 118 | 119 | map({ likes: inc })(users) 120 | 121 | map(evolve({ likes: inc }))(users) 122 | 123 | map(user => ({ ...user, likes: inc }))(users) 124 | ``` 125 | 126 | `adjust` conditionally maps values with a callback function or patch. Value(s) to map can be specified with an index or predicate function. Negative indexes are treated as offsets from the array length. 127 | 128 | ```javascript 129 | import { adjust } from "evolve-ts" 130 | 131 | // set the first user as preferred 132 | adjust(0, { preferred: true })(users) 133 | 134 | // set the last user as preferred 135 | adjust(-1, { preferred: true })(users) 136 | 137 | // set all users who have 100 or more likes as preferred 138 | adjust(user => user.likes > 99, { preferred: true })(users) 139 | 140 | // like map, can also take a callback to update the value 141 | adjust(0, user => ({ ...user, preferred: true }))(users) 142 | ``` 143 | 144 | Other array helpers such as `append` and `filter` are not re-implemented by evolve-ts as they are already included in [fp-ts](https://www.npmjs.com/package/fp-ts), [Ramda](https://www.npmjs.com/package/ramda), and many other libraries. 145 | 146 | ```javascript 147 | import { adjust, evolve, map } from "evolve-ts" 148 | import { append, filter, inc } from "" // your favorite functional utility library 149 | 150 | 151 | const state = { 152 | users: [ 153 | { name: "Alice", age: 22, id: 0 }, 154 | { name: "Bob", age: 33, id: 1 } 155 | ] 156 | } 157 | 158 | // add a new user 159 | evolve({ 160 | users: append({ name: "Claire", age: 44, id: 2 }) 161 | }, state) 162 | 163 | // increment the ages of all users 164 | evolve({ 165 | users: map({ age: inc }) 166 | }, state) 167 | 168 | // set the age of a user by id 169 | evolve({ 170 | users: adjust(user => user.id === 1, { age: 55 }) 171 | }, state) 172 | 173 | // remove a user by id 174 | evolve({ 175 | users: filter(user => user.id !== 1) 176 | }, state) 177 | ``` 178 | 179 | ## Currying 180 | 181 | ```javascript 182 | import { evolve } from "evolve-ts" 183 | 184 | const incrementAge = evolve({ age: age => age + 1 }) 185 | 186 | incrementAge({ name: "Alice", age: 22 }) 187 | // { name: "Alice", age: 23 } 188 | ``` 189 | 190 | ## TypeScript Support 191 | 192 | The `evolve` function is strictly typed and does not allow polymorphism. The return type is always the same as the target type. 193 | ```typescript 194 | import { evolve, unset } from "evolve-ts" 195 | 196 | // cannot change the type of values in target 197 | evolve({ age: "22" }, { name: "Alice", age: 22 }) 198 | // TypeError: Type 'string' is not assignable to type 'number | ((value: number) => number)' 199 | 200 | // updaters have correctly inferred types (updater argument in this example is inferred as number) 201 | evolve({ age: age => age + 1 }, { name: "Alice", age: 22 }) 202 | // ReturnType: { name: string; age: number; } 203 | 204 | // unset can be used on optional keys 205 | evolve({ age: unset }, { name: "Alice", age: 22 } as { name: string; age?: number; }) 206 | // ReturnType: { name: string; age?: number; } 207 | 208 | // unset cannot be used on required keys 209 | evolve({ age: unset }, { name: "Alice", age: 22 }) 210 | // TypeError: Type 'typeof Unset' is not assignable to type 'number | ((value: number) => number)' 211 | 212 | // cannot add extraneous properties to the patch 213 | evolve({ name: "Alice", age: 23 }, { age: 22 }) 214 | // TypeError: ...'name' does not exist in type 'Patch<{ age: number; }>' 215 | 216 | // cannot set non-nullable properties to undefined when exactOptionalPropertyTypes is enabled 217 | evolve({ name: undefined }, { name: "Alice" }) 218 | // Type 'undefined' is not assignable to type 'string | ((value: string) => string)' 219 | ``` 220 | 221 | The `evolve_` function is a type alias for `evolve` that allows polymorphism while still producing strongly typed results. 222 | ```typescript 223 | import { evolve_, unset } from "evolve-ts" 224 | 225 | // changing age from number to string 226 | evolve_({ age: "22" }, { name: "Alice", age: 22 }) 227 | // ReturnType: { name: string; age: string; } 228 | 229 | // adding name key 230 | evolve_({ name: "Alice", age: 23 }, { age: 22 }) 231 | // ReturnType: { name: string, age: number; } 232 | 233 | // adding age key with updater function 234 | evolve_({ age: (): number => 22 }, { name: "Alice" }) 235 | // ReturnType: { name: string, age: number; } 236 | 237 | // removing age key 238 | evolve_({ age: unset }, { name: "Alice", age: 22 }) 239 | // ReturnType: { name: string; } 240 | ``` 241 | 242 | ## Shallow Updates 243 | 244 | The behavior of `shallowEvolve` is similar to the spread operator except it accepts updater functions. 245 | 246 | ```typescript 247 | import { evolve, shallowEvolve } from "evolve-ts" 248 | 249 | declare const user 250 | 251 | shallowEvolve({ user }) /* equivalent to */ evolve({ user: () => user }) 252 | 253 | // evolve can be used within shallowEvolve if a deep merge is needed for a particular key 254 | shallowEvolve({ user: evolve(user) }) /* equivalent to */ evolve({ user }) 255 | ``` 256 | 257 | ## Provided Functions 258 | 259 | - `evolve`: Takes a patch object and a target object and returns a version of the target object with updates from the patch applied. A patch is a subset of the target object containing either values or functions to update values. Functions are called with existing values from the target, non-object values are set into the target, and object values are merged recursively. 260 | - `evolve_`: Type alias for evolve that allows polymorphism while still producing strongly typed results. 261 | - `map`: Maps values in an array with a callback function or patch. 262 | - `adjust`: Conditionally maps values in an array with a callback function or patch. Value(s) to map can be specified with an index or predicate function. Negative indexes are treated as offsets from the array length. 263 | - `unset`: Sentinel value that causes its key to be removed from the output. 264 | - `shallowEvolve`: Like `evolve` but performs shallow updates. 265 | - `shallowMap`: Like `map` but performs shallow updates. 266 | - `shallowAdjust`: Like `adjust` but performs shallow updates. 267 | 268 | 269 | ## Why evolve-ts? 270 | 271 | evolve-ts was created as a lightweight alternative to [updeep](https://www.npmjs.com/package/updeep) and [immer](https://www.npmjs.com/package/immer). It has all of updeep's core functionality, strong TypeScript support, is only a fraction of the size of updeep or immer, and is dependency free. 272 | 273 | ## Caveats 274 | 275 | evolve-ts treats all functions in patches as updater functions, so if your state contains function values you want to update you must wrap the updates in a `constant` function. 276 | 277 | ```typescript 278 | import { evolve } from "evolve-ts" 279 | 280 | const state = { 281 | name: "Alice", 282 | greet: (person) => console.log(`Hi ${person} I'm Alice!`) 283 | } 284 | // need to use a wrapper to set new function values 285 | evolve({ 286 | name: "Bob", 287 | greet: () => (person) => console.log(`Hi ${person} I'm Bob!`) 288 | }, state) 289 | ``` 290 | 291 | ## License 292 | 293 | MIT 294 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | class Unset {} // using class so the type will be unique 2 | 3 | /** sentinel value that will cause its key to be removed */ 4 | export const unset = Unset 5 | 6 | type Unarray = T extends Array ? U : T 7 | 8 | type OptionalKeysHelper = { 9 | [K in keyof T]-?: Record extends Pick ? K : never 10 | }[keyof T] 11 | type IsOptional = string extends keyof T // all keys are optional in an index type 12 | ? true 13 | : [OptionalKeysHelper] extends [never] // if no optional keys in type, false 14 | ? false 15 | : K extends OptionalKeysHelper 16 | ? true 17 | : false 18 | 19 | export type Patch = { 20 | [K in keyof Target]?: Target[K] extends any[] 21 | ? IsOptional extends true 22 | ? 23 | | Target[K] 24 | | ((value: Target[K]) => Target[K] | typeof Unset) 25 | | typeof Unset 26 | : Target[K] | ((value: Target[K]) => Target[K]) 27 | : Target[K] extends { [key: string]: any } 28 | ? IsOptional extends true 29 | ? 30 | | Target[K] 31 | | ((value: Target[K]) => Target[K] | typeof Unset) 32 | | typeof Unset 33 | | Patch 34 | : Target[K] | ((value: Target[K]) => Target[K]) | Patch 35 | : IsOptional extends true 36 | ? 37 | | Target[K] 38 | | ((value: Target[K]) => Target[K] | typeof Unset) 39 | | typeof Unset 40 | : Target[K] | ((value: Target[K]) => Target[K]) 41 | } 42 | 43 | export type ShallowPatch = { 44 | [K in keyof Target]?: IsOptional extends true 45 | ? 46 | | Target[K] 47 | | ((value: Target[K]) => Target[K] | typeof Unset) 48 | | typeof Unset 49 | : Target[K] | ((value: Target[K]) => Target[K]) 50 | } 51 | 52 | export interface Evolve { 53 | ( 54 | patch: Patch< 55 | Context extends (...args: infer Param) => any 56 | ? Param 57 | : Context extends { [key: string]: any } 58 | ? Context 59 | : never 60 | > 61 | ): Context extends (...args: any) => any 62 | ? Context 63 | : (target: Context) => Context 64 | (patch: Patch): ( 65 | target: Target 66 | ) => Target // needed for using evolve within patch, not sure why... 67 | ( 68 | patch: Patch, 69 | target: Target 70 | ): Target 71 | } 72 | 73 | export interface ShallowEvolve { 74 | ( 75 | patch: ShallowPatch< 76 | Context extends (...args: infer Param) => any 77 | ? Param 78 | : Context extends { [key: string]: any } 79 | ? Context 80 | : never 81 | > 82 | ): Context extends (...args: any) => any 83 | ? Context 84 | : (target: Context) => Context 85 | (patch: ShallowPatch): ( 86 | target: Target 87 | ) => Target // needed for using evolve within patch, not sure why... 88 | ( 89 | patch: ShallowPatch, 90 | target: Target 91 | ): Target 92 | } 93 | 94 | export interface Adjust { 95 | /** curried form for use within evolve or setState */ 96 | ( 97 | indexOrPredicate: number | ((item: Unarray) => any), 98 | patchOrUpdater: 99 | | Patch> 100 | | ((item: Unarray) => Unarray) 101 | ): (array: Arr) => Arr 102 | /** uncurried form and explicit array type */ 103 | ( 104 | indexOrPredicate: number | ((item: Unarray) => any), 105 | patchOrUpdater: 106 | | Patch> 107 | | ((item: Unarray) => Unarray), 108 | array: Arr 109 | ): Arr 110 | } 111 | 112 | export interface ShallowAdjust { 113 | /** curried form for use within evolve or setState */ 114 | ( 115 | indexOrPredicate: number | ((item: Unarray) => any), 116 | patchOrUpdater: 117 | | ShallowPatch> 118 | | ((item: Unarray) => Unarray) 119 | ): (array: Arr) => Arr 120 | /** uncurried form and explicit array type */ 121 | ( 122 | indexOrPredicate: number | ((item: Unarray) => any), 123 | patchOrUpdater: 124 | | ShallowPatch> 125 | | ((item: Unarray) => Unarray), 126 | array: Arr 127 | ): Arr 128 | } 129 | 130 | export interface MapArray { 131 | /** curried form for use within evolve or setState */ 132 | ( 133 | patchOrUpdater: 134 | | Patch> 135 | | ((item: Unarray) => Unarray) 136 | ): (array: Arr) => Arr 137 | /** uncurried form and explicit array type */ 138 | ( 139 | patchOrUpdater: 140 | | Patch> 141 | | ((item: Unarray) => Unarray), 142 | array: Arr 143 | ): Arr 144 | } 145 | 146 | export interface ShallowMapArray { 147 | /** curried form for use within evolve or setState */ 148 | ( 149 | patchOrUpdater: 150 | | ShallowPatch> 151 | | ((item: Unarray) => Unarray) 152 | ): (array: Arr) => Arr 153 | /** uncurried form and explicit array type */ 154 | ( 155 | patchOrUpdater: 156 | | ShallowPatch> 157 | | ((item: Unarray) => Unarray), 158 | array: Arr 159 | ): Arr 160 | } 161 | 162 | export interface Evolve_ { 163 | (patch: Patch): ( 164 | target: Target 165 | ) => MergeLeft, Target> 166 | ( 167 | patch: Patch, 168 | target: Target 169 | ): MergeLeft, Target> 170 | } 171 | 172 | type ValueOf = T[keyof T] 173 | type Id = T extends infer U ? { [K in keyof U]: U[K] } : never 174 | type NonNeverKeys = { 175 | [K in keyof T]: T[K] extends never ? never : K 176 | }[keyof T] 177 | type FilterNeverKeys = { 178 | [K in NonNeverKeys]: T[K] 179 | } 180 | 181 | type KeysOfType = { [K in keyof T]: T[K] extends U ? K : never }[keyof T] 182 | type RequiredKeys = Exclude< 183 | KeysOfType>, 184 | undefined 185 | > 186 | type OptionalKeys = Exclude> 187 | 188 | type IfEquals = A extends B ? (B extends A ? T : F) : F 189 | 190 | type ParsePatch = Patch extends any[] 191 | ? Patch 192 | : Patch extends (...args: any[]) => any 193 | ? Exclude, typeof Unset> 194 | : Patch extends typeof Unset 195 | ? never 196 | : Patch extends { [key: string]: any } 197 | ? string extends keyof Target // if target is an index type, return an index type of the patch excluding Unset 198 | ? { 199 | [key: string]: ParsePatch< 200 | [Exclude, Unset>] extends [never] 201 | ? any 202 | : Exclude, Unset>, 203 | unknown 204 | > 205 | } 206 | : Target extends { [key: string]: any } 207 | ? Id< 208 | { 209 | [K in Exclude]: ParsePatch< 210 | Patch[K], 211 | unknown 212 | > // unique keys in Patch 213 | } & Partial< 214 | // if key is optional in target it should be typed as optional in patch to avoid type errors as target has to extend patch 215 | // filter out Unset keys in Patch if the corresponding key in target is optional, this allows unsetting optional keys without changing the type 216 | FilterNeverKeys<{ 217 | [K in OptionalKeys< 218 | Pick 219 | >]: IfEquals< 220 | Patch[K], 221 | Unset, 222 | never, 223 | ParsePatch 224 | > 225 | }> 226 | > & { 227 | [K in RequiredKeys< 228 | Pick 229 | >]: IfEquals< 230 | Patch[K], 231 | Unset, 232 | never, 233 | ParsePatch 234 | > 235 | } // required keys in target 236 | > 237 | : { 238 | [K in keyof Patch]: Patch[K] extends Unset 239 | ? never 240 | : ParsePatch 241 | } 242 | : Patch 243 | 244 | export type MergeLeft = L extends any[] 245 | ? L 246 | : string extends keyof L 247 | ? string extends keyof R 248 | ? { [key: string]: MergeLeft, ValueOf> } 249 | : R extends { [key: string]: any } 250 | ? { [key: string]: ValueOf | ValueOf } 251 | : L 252 | : L extends { [key: string]: any } 253 | ? string extends keyof R 254 | ? { [key: string]: ValueOf | ValueOf } 255 | : R extends { [key: string]: any } 256 | ? Id< 257 | Pick> & // unique keys in R 258 | Pick> & // unique keys in L 259 | Partial< 260 | FilterNeverKeys<{ 261 | [K in OptionalKeys< 262 | Pick 263 | >]: MergeLeft 264 | }> 265 | > & // merge optional shared keys 266 | FilterNeverKeys<{ 267 | [K in RequiredKeys< 268 | Pick 269 | >]: MergeLeft 270 | }> // merge required shared keys 271 | > 272 | : L 273 | : L extends R 274 | ? R 275 | : L // prevent type narrowing 276 | -------------------------------------------------------------------------------- /test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | adjust, 3 | evolve, 4 | evolve_, 5 | map, 6 | shallowAdjust, 7 | shallowEvolve, 8 | shallowMap, 9 | unset, 10 | } from "../src" 11 | 12 | const state = { 13 | user: { 14 | name: "Alice", 15 | age: 22, 16 | friends: [ 17 | { name: "Bob", age: 33 }, 18 | { name: "Claire", age: 44 }, 19 | ], 20 | interests: { 21 | tea: true, 22 | mushrooms: true, 23 | } as Record, 24 | meta: { 25 | active: true, 26 | deleted: false, 27 | }, 28 | }, 29 | foo: { 30 | a: false, 31 | b: false, 32 | }, 33 | } 34 | const user = state.user 35 | 36 | const update = 37 | (state: State) => 38 | (updater: (s: State) => State): State => 39 | updater(state) 40 | 41 | describe("the evolve function", () => { 42 | it("performs a deep merge of the passed in objects", () => { 43 | const userCopy = { ...user } 44 | const a: typeof user = evolve({ age: 33 }, user) 45 | expect(a).toEqual({ 46 | ...user, 47 | age: 33, 48 | }) 49 | expect(a).not.toBe(user) 50 | expect(userCopy).toEqual(user) 51 | const newInterests = { 52 | mushrooms: false, 53 | } 54 | const b: typeof state = evolve( 55 | { 56 | user: { 57 | age: 33, 58 | friends: [{ name: "Claire", age: 44 }], 59 | interests: newInterests, 60 | }, 61 | foo: { 62 | b: true, 63 | }, 64 | }, 65 | state 66 | ) 67 | expect(b).toEqual({ 68 | ...state, 69 | user: { 70 | ...user, 71 | age: 33, 72 | friends: [{ name: "Claire", age: 44 }], 73 | interests: { 74 | ...user.interests, 75 | ...newInterests, 76 | }, 77 | }, 78 | foo: { 79 | ...state.foo, 80 | b: true, 81 | }, 82 | }) 83 | expect(b.user.interests).not.toBe(newInterests) 84 | }) 85 | 86 | it("treats functions in the patch object as updates for keys", () => { 87 | const a: typeof user = evolve({ age: (age) => age + 11 }, user) 88 | expect(a).toEqual({ 89 | ...user, 90 | age: 33, 91 | }) 92 | const b: typeof state = evolve( 93 | { 94 | user: { 95 | age: (age) => age + 11, 96 | friends: (friends: any[]) => [ 97 | ...friends, 98 | { name: "Dave", age: 55 }, 99 | ], 100 | interests: { 101 | mushrooms: (bool: boolean) => !bool, 102 | }, 103 | }, 104 | foo: { 105 | b: true, 106 | }, 107 | }, 108 | state 109 | ) 110 | expect(b).toEqual({ 111 | ...state, 112 | user: { 113 | ...user, 114 | age: 33, 115 | friends: [ 116 | { name: "Bob", age: 33 }, 117 | { name: "Claire", age: 44 }, 118 | { name: "Dave", age: 55 }, 119 | ], 120 | interests: { 121 | ...user.interests, 122 | mushrooms: false, 123 | }, 124 | }, 125 | foo: { 126 | ...state.foo, 127 | b: true, 128 | }, 129 | }) 130 | }) 131 | 132 | it("omits keys when their value is the unset helper", () => { 133 | // only optional keys can be removed 134 | const a: Partial = evolve( 135 | { foo: unset }, 136 | state as Partial 137 | ) 138 | expect(a).toEqual({ user }) 139 | const b: typeof user = evolve( 140 | { interests: { mushrooms: unset, tea: false } }, 141 | user 142 | ) 143 | expect(b).toEqual({ 144 | ...user, 145 | interests: { 146 | tea: false, 147 | }, 148 | }) 149 | const c: typeof user = evolve( 150 | { interests: { mushrooms: () => unset, tea: false } }, 151 | user 152 | ) 153 | expect(c).toEqual({ 154 | ...user, 155 | interests: { 156 | tea: false, 157 | }, 158 | }) 159 | }) 160 | 161 | it("has a curried form", () => { 162 | const a: typeof user = evolve({ age: 33 })(user) 163 | expect(a).toEqual({ 164 | ...user, 165 | age: 33, 166 | }) 167 | const b: typeof user = evolve({ 168 | age: (age) => age + 11, 169 | })(user) 170 | expect(b).toEqual({ 171 | ...user, 172 | age: 33, 173 | }) 174 | const c: Partial = evolve>({ 175 | foo: unset, 176 | })(state as Partial) 177 | expect(c).toEqual({ user }) 178 | const untyped = evolve as any 179 | expect(untyped()()()()({ age: 33 })(user)).toEqual({ 180 | ...user, 181 | age: 33, 182 | }) 183 | }) 184 | 185 | it("can be used within a patch", () => { 186 | expect( 187 | evolve( 188 | { 189 | user: { 190 | friends: (friends) => 191 | friends.map(evolve({ age: (age) => age + 1 })), 192 | }, 193 | }, 194 | state 195 | ) 196 | ).toEqual({ 197 | ...state, 198 | user: { 199 | ...user, 200 | friends: [ 201 | { name: "Bob", age: 34 }, 202 | { name: "Claire", age: 45 }, 203 | ], 204 | }, 205 | }) 206 | 207 | expect( 208 | evolve( 209 | { 210 | user: evolve({ age: (age) => age + 1 }), 211 | }, 212 | state 213 | ) 214 | ).toEqual({ 215 | ...state, 216 | user: { 217 | ...user, 218 | age: 23, 219 | }, 220 | }) 221 | }) 222 | 223 | it("returns the patch (or result of the patch) when the patch is not an object", () => { 224 | expect(evolve(null as any, state)).toEqual(null) 225 | expect(evolve(state, null as any)).toEqual(state) 226 | expect(evolve(null as any, null as any)).toEqual(null) 227 | expect(evolve("foo" as any, state)).toEqual("foo") 228 | expect(evolve(state, "foo" as any)).toEqual(state) 229 | expect(evolve("foo" as any, "foo" as any)).toEqual("foo") 230 | expect(evolve(["foo"] as any, state)).toEqual(["foo"]) 231 | expect(evolve(state, ["foo"] as any)).toEqual(state) 232 | expect(evolve(["foo"] as any, ["foo"] as any)).toEqual(["foo"]) 233 | expect(evolve(42 as any, state)).toEqual(42) 234 | expect(evolve(state, 42 as any)).toEqual(state) 235 | expect(evolve(42 as any, 42 as any)).toEqual(42) 236 | expect(evolve((n: any) => (n + 1) as any, 42 as any)).toEqual(43) 237 | }) 238 | 239 | it("handles being passed objects with different shapes", () => { 240 | const foo = { 241 | foo: { 242 | a: true, 243 | b: true, 244 | }, 245 | } 246 | const bar = { 247 | bar: { 248 | a: false, 249 | b: false, 250 | }, 251 | } 252 | expect(evolve(foo, bar as any)).toEqual({ ...foo, ...bar }) 253 | expect(evolve(bar, foo as any)).toEqual({ ...foo, ...bar }) 254 | expect(evolve({ foo: () => true }, {} as any)).toEqual({ foo: true }) 255 | expect(evolve({ foo: {} }, foo)).toEqual(foo) 256 | expect(evolve({ foo: "foo" }, foo as any)).toEqual({ foo: "foo" }) 257 | expect(evolve(foo, { foo: "foo" } as any)).toEqual(foo) 258 | expect(evolve({ foo: ["foo"] }, foo as any)).toEqual({ foo: ["foo"] }) 259 | expect(evolve(foo, { foo: ["foo"] } as any)).toEqual(foo) 260 | }) 261 | 262 | it("has correct typings", () => { 263 | const tagState = { 264 | tags: { 265 | foo: true, 266 | bar: true, 267 | } as { [key: string]: boolean }, 268 | } 269 | 270 | // uncomment to confirm type errors 271 | // const error1 = evolve({ age: "22" }, { name: "Alice", age: 22 }) 272 | // const error2 = evolve({ name: "Alice", age: 22 }, { name: "Bob" }) 273 | // const error3 = evolve({ name: () => unset }, { name: "Alice", age: 22 }) 274 | // const error4 = evolve({ tags: { baz: "true" } }, tagState) 275 | // const error5 = evolve({ name: undefined }, { name: "Alice" }) 276 | 277 | const a: typeof tagState = evolve({ tags: { baz: true } }, tagState) 278 | expect(a).toEqual({ 279 | tags: { 280 | foo: true, 281 | bar: true, 282 | baz: true, 283 | }, 284 | }) 285 | 286 | const d: { name?: string } = evolve({ name: "Bob" }, { 287 | name: "Alice", 288 | } as { name?: string }) 289 | expect(d).toEqual({ name: "Bob" }) 290 | 291 | const e: typeof tagState = evolve({ tags: { foo: unset } }, tagState) 292 | expect(e).toEqual({ 293 | tags: { 294 | bar: true, 295 | }, 296 | }) 297 | 298 | const f: typeof tagState = evolve( 299 | { tags: { foo: unset, bar: false } }, 300 | tagState 301 | ) 302 | expect(f).toEqual({ 303 | tags: { 304 | bar: false, 305 | }, 306 | }) 307 | 308 | const h: { dict: Record } = evolve( 309 | { dict: () => ({ a: "" }) }, 310 | { dict: {} as Record } 311 | ) 312 | expect(h).toEqual({ dict: { a: "" } }) 313 | 314 | type Status = "INIT" | "LOADING" | "SUCCESS" | "FAILURE" 315 | 316 | interface RequestState { 317 | status: Status 318 | data: any 319 | } 320 | const requestState: RequestState = { status: "INIT", data: null } 321 | 322 | const i: RequestState = evolve({ status: "SUCCESS" }, requestState) 323 | expect(i).toEqual({ status: "SUCCESS", data: null }) 324 | 325 | type CategoryKeys = "a" | "b" 326 | type Categories = { [key in CategoryKeys]: string } 327 | 328 | interface CategoryState { 329 | categories: Categories 330 | } 331 | const categoryState: CategoryState = { 332 | categories: { a: "foo", b: "bar" }, 333 | } 334 | let key = "a" 335 | 336 | const j: CategoryState = evolve( 337 | { categories: { [key]: "baz" } }, 338 | categoryState 339 | ) 340 | expect(j).toEqual({ categories: { a: "baz", b: "bar" } }) 341 | }) 342 | 343 | it("infers types correctly in the context of setState or something similar", () => { 344 | const a: { age?: number } = update({ age: 22 } as { age?: number })( 345 | evolve({ age: unset }) 346 | ) 347 | expect(a).toEqual({}) 348 | 349 | const b: { name: "Alice" | "Bob" } = update({ 350 | name: "Alice" as "Alice" | "Bob", 351 | })(evolve({ name: "Bob" })) 352 | expect(b).toEqual({ name: "Bob" }) 353 | 354 | const tagState = { 355 | tags: { 356 | foo: true, 357 | bar: true, 358 | } as { [key: string]: boolean }, 359 | } 360 | 361 | const c: typeof tagState = update(tagState)( 362 | evolve({ tags: { baz: true } }) 363 | ) 364 | expect(c).toEqual({ 365 | tags: { 366 | foo: true, 367 | bar: true, 368 | baz: true, 369 | }, 370 | }) 371 | 372 | const d: { name?: string } = update({ name: "Alice" } as { 373 | name?: string 374 | })(evolve({ name: "Bob" })) 375 | expect(d).toEqual({ name: "Bob" }) 376 | 377 | const e: typeof tagState = update(tagState)( 378 | evolve({ tags: { foo: unset } }) 379 | ) 380 | expect(e).toEqual({ 381 | tags: { 382 | bar: true, 383 | }, 384 | }) 385 | 386 | const f: typeof tagState = update(tagState)( 387 | evolve({ tags: { foo: unset, bar: false } }) 388 | ) 389 | expect(f).toEqual({ 390 | tags: { 391 | bar: false, 392 | }, 393 | }) 394 | }) 395 | 396 | it("has an evolve_ type alias", () => { 397 | const b: { 398 | other: number 399 | tags: { 400 | foo: boolean 401 | baz: string 402 | bar: string 403 | } 404 | } = evolve_( 405 | { tags: { bar: "true", baz: "true" } }, 406 | { tags: { foo: true, bar: true }, other: 1 } 407 | ) 408 | expect(b).toEqual({ 409 | tags: { foo: true, bar: "true", baz: "true" }, 410 | other: 1, 411 | }) 412 | 413 | const c = evolve_({ name: unset }, { name: "Alice", age: 22 }) 414 | expect(c).toEqual({ age: 22 }) 415 | 416 | const d = evolve_({ name: () => unset }, { name: "Alice", age: 22 }) 417 | expect(d).toEqual({ age: 22 }) 418 | 419 | const g: { name: string; age: number } = evolve_( 420 | { age: () => 22 }, 421 | { name: "Alice" } 422 | ) 423 | expect(g).toEqual({ name: "Alice", age: 22 }) 424 | }) 425 | }) 426 | 427 | describe("the array helpers", () => { 428 | it("has a map function", () => { 429 | const a: { 430 | name: string 431 | age: number 432 | }[] = map({ age: 55 }, user.friends) 433 | expect(a).toEqual([ 434 | { name: "Bob", age: 55 }, 435 | { name: "Claire", age: 55 }, 436 | ]) 437 | 438 | const b: { 439 | name: string 440 | age: number 441 | }[] = map( 442 | (friend) => ({ ...friend, age: friend.age + 1 }), 443 | user.friends 444 | ) 445 | expect(b).toEqual([ 446 | { name: "Bob", age: 34 }, 447 | { name: "Claire", age: 45 }, 448 | ]) 449 | 450 | const c: { 451 | name: string 452 | age: number 453 | }[] = map(evolve({ age: (age) => age + 1 }), user.friends) 454 | expect(c).toEqual([ 455 | { name: "Bob", age: 34 }, 456 | { name: "Claire", age: 45 }, 457 | ]) 458 | 459 | const d: typeof state = evolve( 460 | { 461 | user: { 462 | friends: map({ age: (age) => age + 1 }), 463 | }, 464 | }, 465 | state 466 | ) 467 | expect(d).toEqual({ 468 | ...state, 469 | user: { 470 | ...user, 471 | friends: [ 472 | { name: "Bob", age: 34 }, 473 | { name: "Claire", age: 45 }, 474 | ], 475 | }, 476 | }) 477 | }) 478 | 479 | it("has an adjust function", () => { 480 | const a: { 481 | name: string 482 | age: number 483 | }[] = adjust(0, { age: 55 }, user.friends) 484 | expect(a).toEqual([ 485 | { name: "Bob", age: 55 }, 486 | { name: "Claire", age: 44 }, 487 | ]) 488 | 489 | const b: { 490 | name: string 491 | age: number 492 | }[] = adjust(-1, { age: 55 }, user.friends) 493 | expect(b).toEqual([ 494 | { name: "Bob", age: 33 }, 495 | { name: "Claire", age: 55 }, 496 | ]) 497 | 498 | const c: { 499 | name: string 500 | age: number 501 | }[] = adjust((user) => user.name === "Bob", { age: 55 }, user.friends) 502 | expect(c).toEqual([ 503 | { name: "Bob", age: 55 }, 504 | { name: "Claire", age: 44 }, 505 | ]) 506 | 507 | const d: { 508 | name: string 509 | age: number 510 | }[] = adjust( 511 | 0, 512 | (friend) => ({ ...friend, age: friend.age + 1 }), 513 | user.friends 514 | ) 515 | expect(d).toEqual([ 516 | { name: "Bob", age: 34 }, 517 | { name: "Claire", age: 44 }, 518 | ]) 519 | 520 | const e: typeof state = evolve( 521 | { 522 | user: { 523 | friends: adjust(0, { age: (age) => age + 1 }), 524 | }, 525 | }, 526 | state 527 | ) 528 | expect(e).toEqual({ 529 | ...state, 530 | user: { 531 | ...user, 532 | friends: [ 533 | { name: "Bob", age: 34 }, 534 | { name: "Claire", age: 44 }, 535 | ], 536 | }, 537 | }) 538 | // returns the original array if no items were changed 539 | const f: { 540 | name: string 541 | age: number 542 | }[] = adjust(3, { age: 55 }, user.friends) 543 | expect(f).toBe(user.friends) 544 | }) 545 | }) 546 | 547 | describe("the shallow evolve function", () => { 548 | it("performs a shallow merge of the passed in objects", () => { 549 | const a: typeof user = shallowEvolve({ age: 33 }, user) 550 | expect(a).toEqual({ 551 | ...user, 552 | age: 33, 553 | }) 554 | const newInterests = { rabbits: true } 555 | const b: typeof user = shallowEvolve( 556 | { ...user, interests: newInterests }, 557 | user 558 | ) 559 | expect(b).toEqual({ 560 | ...user, 561 | interests: newInterests, 562 | }) 563 | expect(b.interests).toBe(newInterests) 564 | }) 565 | 566 | it("treats functions in the patch object as updates for keys", () => { 567 | const a: typeof user = shallowEvolve({ age: (age) => age + 11 }, user) 568 | expect(a).toEqual({ 569 | ...user, 570 | age: 33, 571 | }) 572 | const b: typeof user = shallowEvolve( 573 | { 574 | friends: (friends: any[]) => [ 575 | ...friends, 576 | { name: "Dave", age: 55 }, 577 | ], 578 | }, 579 | user 580 | ) 581 | expect(b).toEqual({ 582 | ...user, 583 | friends: [ 584 | { name: "Bob", age: 33 }, 585 | { name: "Claire", age: 44 }, 586 | { name: "Dave", age: 55 }, 587 | ], 588 | }) 589 | }) 590 | 591 | it("omits keys when their value is the unset helper", () => { 592 | // only optional keys can be removed 593 | const a: Partial = shallowEvolve( 594 | { foo: unset }, 595 | state as Partial 596 | ) 597 | expect(a).toEqual({ user }) 598 | const b: Partial = shallowEvolve( 599 | { foo: () => unset }, 600 | state as Partial 601 | ) 602 | expect(b).toEqual({ user }) 603 | }) 604 | 605 | it("has a curried form", () => { 606 | const a: typeof user = shallowEvolve({ age: 33 })(user) 607 | expect(a).toEqual({ 608 | ...user, 609 | age: 33, 610 | }) 611 | const b: typeof user = shallowEvolve({ 612 | age: (age) => age + 11, 613 | })(user) 614 | expect(b).toEqual({ 615 | ...user, 616 | age: 33, 617 | }) 618 | const untyped = shallowEvolve as any 619 | expect(untyped()()()()({ age: 33 })(user)).toEqual({ 620 | ...user, 621 | age: 33, 622 | }) 623 | }) 624 | 625 | it("can be used within a patch", () => { 626 | expect( 627 | shallowEvolve( 628 | { 629 | meta: shallowEvolve({ active: false }), 630 | }, 631 | user 632 | ) 633 | ).toEqual({ 634 | ...user, 635 | meta: { 636 | active: false, 637 | deleted: false, 638 | }, 639 | }) 640 | }) 641 | 642 | it("has correct typings", () => { 643 | const tagState = { 644 | tags: { 645 | foo: true, 646 | bar: true, 647 | } as { [key: string]: boolean }, 648 | } 649 | 650 | // uncomment to confirm type errors 651 | // const error1 = shallowEvolve({ age: "22" }, { name: "Alice", age: 22 }) 652 | // const error2 = shallowEvolve({ name: "Alice", age: 22 }, { name: "Bob" }) 653 | // const error3 = shallowEvolve({ name: undefined }, { name: "Alice" }) 654 | // const error4 = shallowEvolve({ name: () => unset }, { name: "Alice" }) 655 | 656 | const a: typeof tagState = shallowEvolve( 657 | { tags: { baz: true } }, 658 | tagState 659 | ) 660 | expect(a).toEqual({ tags: { baz: true } }) 661 | 662 | const d: { name?: string } = shallowEvolve({ name: "Bob" }, { 663 | name: "Alice", 664 | } as { name?: string }) 665 | expect(d).toEqual({ name: "Bob" }) 666 | 667 | type Status = "INIT" | "LOADING" | "SUCCESS" | "FAILURE" 668 | 669 | interface RequestState { 670 | status: Status 671 | data: any 672 | } 673 | const requestState: RequestState = { status: "INIT", data: null } 674 | 675 | const e: RequestState = shallowEvolve( 676 | { status: "SUCCESS" }, 677 | requestState 678 | ) 679 | expect(e).toEqual({ status: "SUCCESS", data: null }) 680 | 681 | type CategoryKeys = "a" | "b" 682 | type Categories = { [key in CategoryKeys]: string } 683 | const categories: Categories = { a: "foo", b: "bar" } 684 | let key = "a" 685 | 686 | const f: Categories = shallowEvolve({ [key]: "baz" }, categories) 687 | expect(f).toEqual({ a: "baz", b: "bar" }) 688 | 689 | const g: { name?: string } = shallowEvolve<{ name?: string }>( 690 | { name: unset }, 691 | { name: "Alice" } 692 | ) 693 | expect(g).toEqual({}) 694 | }) 695 | 696 | it("infers types correctly in the context of setState or something similar", () => { 697 | const a: { age: number } = update({ age: 22 })( 698 | shallowEvolve({ age: (age) => age + 1 }) 699 | ) 700 | expect(a).toEqual({ age: 23 }) 701 | 702 | const b: { name: "Alice" | "Bob" } = update({ 703 | name: "Alice" as "Alice" | "Bob", 704 | })(shallowEvolve({ name: "Bob" })) 705 | expect(b).toEqual({ name: "Bob" }) 706 | 707 | const tagState = { 708 | tags: { 709 | foo: true, 710 | bar: true, 711 | } as { [key: string]: boolean }, 712 | } 713 | 714 | const c: typeof tagState = update(tagState)( 715 | shallowEvolve({ tags: { baz: true } }) 716 | ) 717 | expect(c).toEqual({ 718 | tags: { 719 | baz: true, 720 | }, 721 | }) 722 | 723 | const d: { name?: string } = update({ name: "Alice" } as { 724 | name?: string 725 | })(shallowEvolve({ name: "Bob" })) 726 | expect(d).toEqual({ name: "Bob" }) 727 | }) 728 | }) 729 | 730 | describe("the shallow array helpers", () => { 731 | it("has a map function", () => { 732 | const a: { 733 | name: string 734 | age: number 735 | }[] = shallowMap({ age: 55 }, user.friends) 736 | expect(a).toEqual([ 737 | { name: "Bob", age: 55 }, 738 | { name: "Claire", age: 55 }, 739 | ]) 740 | 741 | const b: { 742 | name: string 743 | age: number 744 | }[] = shallowMap( 745 | (friend) => ({ ...friend, age: friend.age + 1 }), 746 | user.friends 747 | ) 748 | expect(b).toEqual([ 749 | { name: "Bob", age: 34 }, 750 | { name: "Claire", age: 45 }, 751 | ]) 752 | 753 | const c: { 754 | name: string 755 | age: number 756 | }[] = shallowMap(shallowEvolve({ age: (age) => age + 1 }), user.friends) 757 | expect(c).toEqual([ 758 | { name: "Bob", age: 34 }, 759 | { name: "Claire", age: 45 }, 760 | ]) 761 | }) 762 | 763 | it("has an adjust function", () => { 764 | const a: { 765 | name: string 766 | age: number 767 | }[] = shallowAdjust(0, { age: 55 }, user.friends) 768 | expect(a).toEqual([ 769 | { name: "Bob", age: 55 }, 770 | { name: "Claire", age: 44 }, 771 | ]) 772 | 773 | const b: { 774 | name: string 775 | age: number 776 | }[] = shallowAdjust(-1, { age: 55 }, user.friends) 777 | expect(b).toEqual([ 778 | { name: "Bob", age: 33 }, 779 | { name: "Claire", age: 55 }, 780 | ]) 781 | 782 | const c: { 783 | name: string 784 | age: number 785 | }[] = shallowAdjust( 786 | (user) => user.name === "Bob", 787 | { age: 55 }, 788 | user.friends 789 | ) 790 | expect(c).toEqual([ 791 | { name: "Bob", age: 55 }, 792 | { name: "Claire", age: 44 }, 793 | ]) 794 | 795 | const d: { 796 | name: string 797 | age: number 798 | }[] = shallowAdjust( 799 | 0, 800 | (friend) => ({ ...friend, age: friend.age + 1 }), 801 | user.friends 802 | ) 803 | expect(d).toEqual([ 804 | { name: "Bob", age: 34 }, 805 | { name: "Claire", age: 44 }, 806 | ]) 807 | }) 808 | }) 809 | --------------------------------------------------------------------------------