├── .npmrc ├── .gitattributes ├── .gitignore ├── .github ├── security.md └── workflows │ └── main.yml ├── .editorconfig ├── package.json ├── license ├── index.test-d.ts ├── readme.md ├── index.js ├── index.d.ts └── test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v5 17 | - uses: actions/setup-node@v5 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "map-obj", 3 | "version": "6.0.0", 4 | "description": "Map object keys and values into a new object", 5 | "license": "MIT", 6 | "repository": "sindresorhus/map-obj", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava && tsd" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "map", 31 | "object", 32 | "key", 33 | "keys", 34 | "value", 35 | "values", 36 | "iterate", 37 | "iterator", 38 | "rename", 39 | "modify", 40 | "deep", 41 | "recurse", 42 | "recursive" 43 | ], 44 | "devDependencies": { 45 | "ava": "^6.4.1", 46 | "tsd": "^0.33.0", 47 | "xo": "^1.2.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType, expectAssignable} from 'tsd'; 2 | import mapObject, {type Options, mapObjectSkip} from './index.js'; 3 | 4 | const options: Options = {}; 5 | 6 | const newObject = mapObject({foo: 'bar'}, (key, value) => [value, key]); 7 | expectAssignable>(newObject); 8 | expectType<'foo'>(newObject.bar); 9 | 10 | const object = mapObject({foo: 'bar'}, (key, value) => [value, key], { 11 | target: {baz: 'baz'}, 12 | }); 13 | expectAssignable<{baz: string} & Record>(object); 14 | expectType<'foo'>(object.bar); 15 | expectType(object.baz); 16 | 17 | const object1 = mapObject({foo: 'bar'}, (key, value) => [value, key], { 18 | target: {baz: 'baz'}, 19 | deep: false, 20 | }); 21 | expectAssignable<{baz: string} & Record>(object1); 22 | expectType<'foo'>(object1.bar); 23 | expectType(object1.baz); 24 | 25 | const object2 = mapObject({foo: 'bar'}, (key, value) => [String(value), key], { 26 | deep: true, 27 | }); 28 | expectAssignable>(object2); 29 | // Deep mapper parameters should be widened 30 | mapObject({fooUpper: true, bAr: {bAz: true}}, (key, value, source) => { 31 | expectAssignable(key); // Without includeSymbols, only string keys 32 | // In deep mode, source is the original input object 33 | expectType<{fooUpper: boolean; bAr: {bAz: boolean}}>(source); 34 | return [String(key), value]; 35 | }, {deep: true}); 36 | 37 | // Shallow mode: source should be the original input type 38 | mapObject({alpha: 1, beta: 2}, (key, value, source) => { 39 | expectType<{alpha: number; beta: number}>(source); 40 | return [key, value]; 41 | }); 42 | const object3 = mapObject({foo: 'bar'}, (key, value) => [String(value), key], { 43 | deep: true, 44 | target: {bar: 'baz' as const}, 45 | }); 46 | expectAssignable>(object3); 47 | expectType<'baz'>(object3.bar); 48 | 49 | mapObject({foo: 'bar'}, (key, value) => [value, key, {shouldRecurse: false}]); 50 | 51 | mapObject({foo: 'bar'}, () => mapObjectSkip); 52 | 53 | // IncludeSymbols option is available 54 | const optionsWithSymbols: Options = {includeSymbols: true}; 55 | 56 | // Test symbol key support 57 | const symbolKey = Symbol('test'); 58 | const inputWithSymbol = {foo: 'bar', [symbolKey]: 'value'}; 59 | 60 | // Test that symbol keys work 61 | const resultWithSymbols = mapObject(inputWithSymbol, (key, value) => { 62 | expectAssignable(key); // With includeSymbols, both string and symbol 63 | return [key, value]; 64 | }, {includeSymbols: true}); 65 | expectAssignable>(resultWithSymbols); 66 | 67 | // Test normal usage 68 | const resultWithoutSymbols = mapObject(inputWithSymbol, (key, value) => { 69 | expectAssignable(key); // Without includeSymbols, only string 70 | return [key, value]; 71 | }); 72 | expectAssignable>(resultWithoutSymbols); 73 | 74 | // Test deep mode with includeSymbols 75 | mapObject({fooUpper: true, [Symbol('test')]: 'symbol'}, (key, value, source) => { 76 | expectAssignable(key); // With includeSymbols in deep mode 77 | return [String(key), value]; 78 | }, {deep: true, includeSymbols: true}); 79 | 80 | // Verify that without includeSymbols, key type excludes symbols 81 | mapObject({str: 'value'}, (key: string, value) => [key, value]); 82 | 83 | // Verify that with includeSymbols, key can be symbol 84 | mapObject({str: 'value'}, (key: string | symbol, value) => [String(key), value], {includeSymbols: true}); 85 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # map-obj 2 | 3 | > Map object keys and values into a new object 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install map-obj 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import mapObject, {mapObjectSkip} from 'map-obj'; 15 | 16 | // Swap keys and values 17 | const newObject = mapObject({foo: 'bar'}, (key, value) => [value, key]); 18 | //=> {bar: 'foo'} 19 | 20 | // Convert keys to lowercase (shallow) 21 | const newObject = mapObject({FOO: true, bAr: {bAz: true}}, (key, value) => [key.toLowerCase(), value]); 22 | //=> {foo: true, bar: {bAz: true}} 23 | 24 | // Convert keys to lowercase (deep recursion) 25 | const newObject = mapObject({FOO: true, bAr: {bAz: true}}, (key, value) => [key.toLowerCase(), value], {deep: true}); 26 | //=> {foo: true, bar: {baz: true}} 27 | 28 | // Filter out specific values 29 | const newObject = mapObject({one: 1, two: 2}, (key, value) => value === 1 ? [key, value] : mapObjectSkip); 30 | //=> {one: 1} 31 | 32 | // Include symbol keys 33 | const symbol = Symbol('foo'); 34 | const newObject = mapObject({bar: 'baz', [symbol]: 'qux'}, (key, value) => [key, value], {includeSymbols: true}); 35 | //=> {bar: 'baz', [Symbol(foo)]: 'qux'} 36 | ``` 37 | 38 | ## API 39 | 40 | ### mapObject(source, mapper, options?) 41 | 42 | #### source 43 | 44 | Type: `object` 45 | 46 | The source object to copy properties from. 47 | 48 | #### mapper 49 | 50 | Type: `(sourceKey, sourceValue, source) => [targetKey, targetValue, mapperOptions?] | mapObjectSkip` 51 | 52 | A mapping function. 53 | 54 | > [!NOTE] 55 | > When `options.deep` is `true`, the mapper receives keys and values from nested objects and arrays. The `sourceKey` parameter is typed as `string | symbol` and `sourceValue` as `unknown` to reflect the actual runtime behavior when recursing into unknown shapes. The third argument `source` is always the original input object, not the current nested owner. 56 | 57 | ##### mapperOptions 58 | 59 | Type: `object` 60 | 61 | ###### shouldRecurse 62 | 63 | Type: `boolean`\ 64 | Default: `true` 65 | 66 | Whether to recurse into `targetValue`. 67 | 68 | Requires `deep: true`. 69 | 70 | #### options 71 | 72 | Type: `object` 73 | 74 | ##### deep 75 | 76 | Type: `boolean`\ 77 | Default: `false` 78 | 79 | Recurse nested objects and objects in arrays. 80 | 81 | Built-in objects like `RegExp`, `Error`, `Date`, `Map`, `Set`, `WeakMap`, `WeakSet`, `Promise`, `ArrayBuffer`, `DataView`, typed arrays (Uint8Array, etc.), and `Blob` are not recursed into. Special objects like Jest matchers are also automatically excluded. 82 | 83 | ##### includeSymbols 84 | 85 | Type: `boolean`\ 86 | Default: `false` 87 | 88 | Include symbol keys in the iteration. 89 | 90 | By default, symbol keys are completely ignored and not passed to the mapper function. When enabled, the mapper will also be called with symbol keys from the source object, allowing them to be transformed or included in the result. Only enumerable symbol properties are included. 91 | 92 | ##### target 93 | 94 | Type: `object`\ 95 | Default: `{}` 96 | 97 | The target object to map properties onto. 98 | 99 | ### mapObjectSkip 100 | 101 | Return this value from a `mapper` function to exclude the key from the new object. 102 | 103 | ```js 104 | import mapObject, {mapObjectSkip} from 'map-obj'; 105 | 106 | const object = {one: 1, two: 2}; 107 | const mapper = (key, value) => value === 1 ? [key, value] : mapObjectSkip; 108 | const result = mapObject(object, mapper); 109 | 110 | console.log(result); 111 | //=> {one: 1} 112 | ``` 113 | 114 | ## Related 115 | 116 | - [filter-obj](https://github.com/sindresorhus/filter-obj) - Filter object keys and values into a new object 117 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const isObject = value => typeof value === 'object' && value !== null; 2 | 3 | // Check if a value is a plain object that should be recursed into 4 | const isObjectCustom = value => { 5 | if (!isObject(value)) { 6 | return false; 7 | } 8 | 9 | // Exclude built-in objects 10 | if ( 11 | value instanceof RegExp 12 | || value instanceof Error 13 | || value instanceof Date 14 | || value instanceof Map 15 | || value instanceof Set 16 | || value instanceof WeakMap 17 | || value instanceof WeakSet 18 | || value instanceof Promise 19 | || value instanceof ArrayBuffer 20 | || value instanceof DataView 21 | || ArrayBuffer.isView(value) // Typed arrays 22 | || (globalThis.Blob && value instanceof globalThis.Blob) 23 | ) { 24 | return false; 25 | } 26 | 27 | // Exclude Jest matchers 28 | if (typeof value.$$typeof === 'symbol' || typeof value.asymmetricMatch === 'function') { 29 | return false; 30 | } 31 | 32 | return true; 33 | }; 34 | 35 | export const mapObjectSkip = Symbol('mapObjectSkip'); 36 | 37 | const getEnumerableKeys = (object, includeSymbols) => { 38 | if (includeSymbols) { 39 | const stringKeys = Object.keys(object); 40 | const symbolKeys = Object.getOwnPropertySymbols(object).filter(symbol => Object.getOwnPropertyDescriptor(object, symbol)?.enumerable); 41 | return [...stringKeys, ...symbolKeys]; 42 | } 43 | 44 | return Object.keys(object); 45 | }; 46 | 47 | const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => { 48 | const { 49 | target = {}, 50 | ...processOptions 51 | } = { 52 | deep: false, 53 | includeSymbols: false, 54 | ...options, 55 | }; 56 | 57 | if (isSeen.has(object)) { 58 | return isSeen.get(object); 59 | } 60 | 61 | isSeen.set(object, target); 62 | 63 | const mapArray = array => array.map(element => isObjectCustom(element) ? _mapObject(element, mapper, processOptions, isSeen) : element); 64 | 65 | if (Array.isArray(object)) { 66 | return mapArray(object); 67 | } 68 | 69 | for (const key of getEnumerableKeys(object, processOptions.includeSymbols)) { 70 | const value = object[key]; 71 | const mapResult = mapper(key, value); 72 | 73 | if (mapResult === mapObjectSkip) { 74 | continue; 75 | } 76 | 77 | if (!Array.isArray(mapResult)) { 78 | throw new TypeError(`Mapper must return an array or mapObjectSkip, got ${mapResult === null ? 'null' : typeof mapResult}`); 79 | } 80 | 81 | if (mapResult.length < 2) { 82 | throw new TypeError(`Mapper must return an array with at least 2 elements [key, value], got ${mapResult.length} elements`); 83 | } 84 | 85 | let [newKey, newValue, {shouldRecurse = true} = {}] = mapResult; 86 | 87 | // Drop `__proto__` keys. 88 | if (newKey === '__proto__') { 89 | continue; 90 | } 91 | 92 | if (processOptions.deep && shouldRecurse && isObjectCustom(newValue)) { 93 | newValue = Array.isArray(newValue) 94 | ? mapArray(newValue) 95 | : _mapObject(newValue, mapper, processOptions, isSeen); 96 | } 97 | 98 | try { 99 | target[newKey] = newValue; 100 | } catch (error) { 101 | if (error.name === 'TypeError' && error.message.includes('read only')) { 102 | // Skip non-configurable properties 103 | continue; 104 | } 105 | 106 | throw error; 107 | } 108 | } 109 | 110 | return target; 111 | }; 112 | 113 | export default function mapObject(object, mapper, options) { 114 | if (!isObject(object)) { 115 | throw new TypeError(`Expected an object, got \`${object}\` (${typeof object})`); 116 | } 117 | 118 | if (Array.isArray(object)) { 119 | throw new TypeError('Expected an object, got an array'); 120 | } 121 | 122 | // Ensure the third mapper argument is always the original input object 123 | const mapperWithRoot = (key, value) => mapper(key, value, object); 124 | 125 | return _mapObject(object, mapperWithRoot, options); 126 | } 127 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Return this value from a `mapper` function to remove a key from an object. 3 | 4 | @example 5 | ``` 6 | import mapObject, {mapObjectSkip} from 'map-obj'; 7 | 8 | const object = {one: 1, two: 2}; 9 | const mapper = (key, value) => value === 1 ? [key, value] : mapObjectSkip; 10 | const result = mapObject(object, mapper); 11 | 12 | console.log(result); 13 | //=> {one: 1} 14 | ``` 15 | */ 16 | export const mapObjectSkip: unique symbol; 17 | 18 | /** 19 | Mapper function for transforming object keys and values. 20 | */ 21 | export type Mapper< 22 | SourceObjectType extends Record, 23 | MappedObjectKeyType extends string | symbol, 24 | MappedObjectValueType, 25 | > = ( 26 | sourceKey: Extract, 27 | sourceValue: SourceObjectType[keyof SourceObjectType], 28 | source: SourceObjectType 29 | ) => [ 30 | targetKey: MappedObjectKeyType, 31 | targetValue: MappedObjectValueType, 32 | mapperOptions?: MapperOptions, 33 | ] | typeof mapObjectSkip; 34 | 35 | /** 36 | Mapper function when `includeSymbols: true` is enabled. 37 | */ 38 | export type MapperWithSymbols< 39 | SourceObjectType extends Record, 40 | MappedObjectKeyType extends string | symbol, 41 | MappedObjectValueType, 42 | > = ( 43 | sourceKey: keyof SourceObjectType, 44 | sourceValue: SourceObjectType[keyof SourceObjectType], 45 | source: SourceObjectType 46 | ) => [ 47 | targetKey: MappedObjectKeyType, 48 | targetValue: MappedObjectValueType, 49 | mapperOptions?: MapperOptions, 50 | ] | typeof mapObjectSkip; 51 | 52 | /** 53 | Mapper used when `{deep: true}` is enabled. 54 | 55 | In deep mode we may visit nested objects with keys and values unrelated to the top-level object, so we intentionally widen the key and value types. 56 | */ 57 | type DeepMapper< 58 | SourceObjectType extends Record, 59 | MappedObjectKeyType extends string | symbol, 60 | MappedObjectValueType, 61 | > = ( 62 | sourceKey: string, 63 | sourceValue: unknown, 64 | source: SourceObjectType 65 | ) => [ 66 | targetKey: MappedObjectKeyType, 67 | targetValue: MappedObjectValueType, 68 | mapperOptions?: MapperOptions, 69 | ] | typeof mapObjectSkip; 70 | 71 | /** 72 | Deep mapper when `includeSymbols: true` is enabled. 73 | */ 74 | type DeepMapperWithSymbols< 75 | SourceObjectType extends Record, 76 | MappedObjectKeyType extends string | symbol, 77 | MappedObjectValueType, 78 | > = ( 79 | sourceKey: string | symbol, 80 | sourceValue: unknown, 81 | source: SourceObjectType 82 | ) => [ 83 | targetKey: MappedObjectKeyType, 84 | targetValue: MappedObjectValueType, 85 | mapperOptions?: MapperOptions, 86 | ] | typeof mapObjectSkip; 87 | 88 | export type Options = { 89 | /** 90 | Recurse nested objects and objects in arrays. 91 | 92 | @default false 93 | 94 | Built-in objects like `RegExp`, `Error`, `Date`, `Map`, `Set`, `WeakMap`, `WeakSet`, `Promise`, `ArrayBuffer`, `DataView`, typed arrays (Uint8Array, etc.), and `Blob` are not recursed into. Special objects like Jest matchers are also automatically excluded. 95 | */ 96 | readonly deep?: boolean; 97 | 98 | /** 99 | Include symbol keys in the iteration. 100 | 101 | By default, symbol keys are completely ignored and not passed to the mapper function. When enabled, the mapper will also be called with symbol keys from the source object, allowing them to be transformed or included in the result. Only enumerable symbol properties are included. 102 | 103 | @default false 104 | */ 105 | readonly includeSymbols?: boolean; 106 | 107 | /** 108 | The target object to map properties onto. 109 | 110 | @default {} 111 | */ 112 | readonly target?: Record; 113 | }; 114 | 115 | export type DeepOptions = { 116 | readonly deep: true; 117 | } & Options; 118 | 119 | export type TargetOptions> = { 120 | readonly target: TargetObjectType; 121 | } & Options; 122 | 123 | export type SymbolOptions = { 124 | readonly includeSymbols: true; 125 | } & Options; 126 | 127 | export type MapperOptions = { 128 | /** 129 | Whether to recurse into `targetValue`. 130 | 131 | Requires `deep: true`. 132 | 133 | @default true 134 | */ 135 | readonly shouldRecurse?: boolean; 136 | }; 137 | 138 | /** 139 | Map object keys and values into a new object. 140 | 141 | @param source - The source object to copy properties from. 142 | @param mapper - A mapping function. 143 | 144 | @example 145 | ``` 146 | import mapObject, {mapObjectSkip} from 'map-obj'; 147 | 148 | // Swap keys and values 149 | const newObject = mapObject({foo: 'bar'}, (key, value) => [value, key]); 150 | //=> {bar: 'foo'} 151 | 152 | // Convert keys to lowercase (shallow) 153 | const newObject = mapObject({FOO: true, bAr: {bAz: true}}, (key, value) => [key.toLowerCase(), value]); 154 | //=> {foo: true, bar: {bAz: true}} 155 | 156 | // Convert keys to lowercase (deep recursion) 157 | const newObject = mapObject({FOO: true, bAr: {bAz: true}}, (key, value) => [key.toLowerCase(), value], {deep: true}); 158 | //=> {foo: true, bar: {baz: true}} 159 | 160 | // Filter out specific values 161 | const newObject = mapObject({one: 1, two: 2}, (key, value) => value === 1 ? [key, value] : mapObjectSkip); 162 | //=> {one: 1} 163 | 164 | // Include symbol keys 165 | const symbol = Symbol('foo'); 166 | const newObject = mapObject({bar: 'baz', [symbol]: 'qux'}, (key, value) => [key, value], {includeSymbols: true}); 167 | //=> {bar: 'baz', [Symbol(foo)]: 'qux'} 168 | ``` 169 | */ 170 | // Overloads with includeSymbols: true 171 | export default function mapObject< 172 | SourceObjectType extends Record, 173 | TargetObjectType extends Record, 174 | MappedObjectKeyType extends string | symbol, 175 | MappedObjectValueType, 176 | >( 177 | source: SourceObjectType, 178 | mapper: DeepMapperWithSymbols, 179 | options: DeepOptions & SymbolOptions & TargetOptions 180 | ): TargetObjectType & Record; 181 | export default function mapObject< 182 | SourceObjectType extends Record, 183 | MappedObjectKeyType extends string | symbol, 184 | MappedObjectValueType, 185 | >( 186 | source: SourceObjectType, 187 | mapper: DeepMapperWithSymbols, 188 | options: DeepOptions & SymbolOptions 189 | ): Record; 190 | export default function mapObject< 191 | SourceObjectType extends Record, 192 | TargetObjectType extends Record, 193 | MappedObjectKeyType extends string | symbol, 194 | MappedObjectValueType, 195 | >( 196 | source: SourceObjectType, 197 | mapper: MapperWithSymbols< 198 | SourceObjectType, 199 | MappedObjectKeyType, 200 | MappedObjectValueType 201 | >, 202 | options: SymbolOptions & TargetOptions 203 | ): TargetObjectType & Record; 204 | export default function mapObject< 205 | SourceObjectType extends Record, 206 | MappedObjectKeyType extends string | symbol, 207 | MappedObjectValueType, 208 | >( 209 | source: SourceObjectType, 210 | mapper: MapperWithSymbols< 211 | SourceObjectType, 212 | MappedObjectKeyType, 213 | MappedObjectValueType 214 | >, 215 | options: SymbolOptions 216 | ): Record; 217 | // Overloads without includeSymbols (default) 218 | export default function mapObject< 219 | SourceObjectType extends Record, 220 | TargetObjectType extends Record, 221 | MappedObjectKeyType extends string | symbol, 222 | MappedObjectValueType, 223 | >( 224 | source: SourceObjectType, 225 | mapper: DeepMapper, 226 | options: DeepOptions & TargetOptions 227 | ): TargetObjectType & Record; 228 | export default function mapObject< 229 | SourceObjectType extends Record, 230 | MappedObjectKeyType extends string | symbol, 231 | MappedObjectValueType, 232 | >( 233 | source: SourceObjectType, 234 | mapper: DeepMapper, 235 | options: DeepOptions 236 | ): Record; 237 | export default function mapObject< 238 | SourceObjectType extends Record, 239 | TargetObjectType extends Record, 240 | MappedObjectKeyType extends string | symbol, 241 | MappedObjectValueType, 242 | >( 243 | source: SourceObjectType, 244 | mapper: Mapper< 245 | SourceObjectType, 246 | MappedObjectKeyType, 247 | MappedObjectValueType 248 | >, 249 | options: TargetOptions 250 | ): TargetObjectType & Record; 251 | export default function mapObject< 252 | SourceObjectType extends Record, 253 | MappedObjectKeyType extends string | symbol, 254 | MappedObjectValueType, 255 | >( 256 | source: SourceObjectType, 257 | mapper: Mapper< 258 | SourceObjectType, 259 | MappedObjectKeyType, 260 | MappedObjectValueType 261 | >, 262 | options?: Options 263 | ): Record; 264 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import mapObject, {mapObjectSkip} from './index.js'; 3 | 4 | test('main', t => { 5 | t.is(mapObject({foo: 'bar'}, key => [key, 'unicorn']).foo, 'unicorn'); 6 | t.is(mapObject({foo: 'bar'}, (key, value) => ['unicorn', value]).unicorn, 'bar'); 7 | t.is(mapObject({foo: 'bar'}, (key, value) => [value, key]).bar, 'foo'); 8 | }); 9 | 10 | test('mapper source argument is always the original input', t => { 11 | const input = {foo: {bar: 1}, a: {b: {c: 1}}}; 12 | 13 | // Test shallow mode 14 | mapObject(input, (key, value, source) => { 15 | t.is(source, input); 16 | return [key, value]; 17 | }); 18 | 19 | // Test deep mode 20 | mapObject(input, (key, value, source) => { 21 | t.is(source, input); // Should always be the root input object, not nested objects 22 | return [key, value]; 23 | }, {deep: true}); 24 | }); 25 | 26 | test('target option', t => { 27 | const input = {x: 1, foo: 'bar'}; 28 | const target = {y: 2}; 29 | 30 | // Test that the function returns the target object and source argument is correct 31 | const result = mapObject(input, (key, value, source) => { 32 | t.is(source, input); 33 | t.not(source, target); 34 | return [key === 'foo' ? 'baz' : key, value]; 35 | }, {target}); 36 | 37 | t.is(result, target); 38 | t.deepEqual(target, {y: 2, x: 1, baz: 'bar'}); 39 | }); 40 | 41 | test('deep option', t => { 42 | const object = { 43 | one: 1, 44 | object: { 45 | two: 2, 46 | three: 3, 47 | }, 48 | array: [ 49 | { 50 | four: 4, 51 | }, 52 | 5, 53 | ], 54 | }; 55 | 56 | const expected = { 57 | one: 2, 58 | object: { 59 | two: 4, 60 | three: 6, 61 | }, 62 | array: [ 63 | { 64 | four: 8, 65 | }, 66 | 5, 67 | ], 68 | }; 69 | 70 | const mapper = (key, value) => [key, typeof value === 'number' ? value * 2 : value]; 71 | const actual = mapObject(object, mapper, {deep: true}); 72 | t.deepEqual(actual, expected); 73 | }); 74 | 75 | test('shouldRecurse mapper option', t => { 76 | const object = { 77 | one: 1, 78 | object: { 79 | two: 2, 80 | three: 3, 81 | }, 82 | array: [ 83 | { 84 | four: 4, 85 | }, 86 | 5, 87 | ], 88 | }; 89 | 90 | const expected = { 91 | one: 2, 92 | object: { 93 | two: 2, 94 | three: 3, 95 | }, 96 | array: [ 97 | { 98 | four: 8, 99 | }, 100 | 5, 101 | ], 102 | }; 103 | 104 | const mapper = (key, value) => { 105 | if (key === 'object') { 106 | return [key, value, {shouldRecurse: false}]; 107 | } 108 | 109 | return [key, typeof value === 'number' ? value * 2 : value]; 110 | }; 111 | 112 | const actual = mapObject(object, mapper, {deep: true}); 113 | t.deepEqual(actual, expected); 114 | }); 115 | 116 | test('nested arrays', t => { 117 | const object = { 118 | array: [ 119 | [ 120 | 0, 121 | 1, 122 | 2, 123 | { 124 | a: 3, 125 | }, 126 | ], 127 | ], 128 | }; 129 | 130 | const expected = { 131 | array: [ 132 | [ 133 | 0, 134 | 1, 135 | 2, 136 | { 137 | a: 6, 138 | }, 139 | ], 140 | ], 141 | }; 142 | 143 | const mapper = (key, value) => [key, typeof value === 'number' ? value * 2 : value]; 144 | const actual = mapObject(object, mapper, {deep: true}); 145 | t.deepEqual(actual, expected); 146 | }); 147 | 148 | test('handles circular references', t => { 149 | const object = { 150 | one: 1, 151 | array: [ 152 | 2, 153 | ], 154 | }; 155 | object.circular = object; 156 | object.array2 = object.array; 157 | object.array.push(object); 158 | 159 | const mapper = (key, value) => [key.toUpperCase(), value]; 160 | const actual = mapObject(object, mapper, {deep: true}); 161 | 162 | const expected = { 163 | ONE: 1, 164 | ARRAY: [ 165 | 2, 166 | ], 167 | }; 168 | expected.CIRCULAR = expected; 169 | expected.ARRAY2 = expected.ARRAY; 170 | expected.ARRAY.push(expected); 171 | 172 | t.deepEqual(actual, expected); 173 | }); 174 | 175 | test('validates input', t => { 176 | t.throws(() => { 177 | mapObject(1, () => {}); 178 | }, { 179 | instanceOf: TypeError, 180 | }); 181 | 182 | t.throws(() => { 183 | mapObject([1, 2], (key, value) => [value, key]); 184 | }, { 185 | instanceOf: TypeError, 186 | }); 187 | }); 188 | 189 | test('__proto__ keys are safely dropped', t => { 190 | const input = {['__proto__']: {one: 1}}; 191 | const output = mapObject(input, (key, value) => [key, value]); 192 | t.deepEqual(output, {}); 193 | 194 | // AVA's equality checking isn't quite strict enough to catch the difference 195 | // between plain objects as prototypes and Object.prototype, so we also check 196 | // the prototype by identity 197 | t.is(Object.getPrototypeOf(output), Object.prototype); 198 | }); 199 | 200 | test('remove keys (#36)', t => { 201 | const object = { 202 | one: 1, 203 | two: 2, 204 | }; 205 | 206 | const expected = { 207 | one: 1, 208 | }; 209 | 210 | const mapper = (key, value) => value === 1 ? [key, value] : mapObjectSkip; 211 | const actual = mapObject(object, mapper, {deep: true}); 212 | t.deepEqual(actual, expected); 213 | }); 214 | 215 | test('should not recurse into Jest-like matchers', t => { 216 | // Mock a Jest asymmetric matcher like expect.anything() 217 | const jestMatcher = { 218 | $$typeof: Symbol.for('jest.asymmetricMatcher'), 219 | asymmetricMatch: () => true, 220 | toString: () => 'expect.anything()', 221 | }; 222 | 223 | const input = { 224 | normal: {nested: 'value'}, 225 | matcher: jestMatcher, 226 | }; 227 | 228 | let calls = 0; 229 | const result = mapObject(input, (key, value) => { 230 | calls++; 231 | // Should not recurse into jestMatcher properties 232 | t.not(key, '$$typeof'); 233 | t.not(key, 'asymmetricMatch'); 234 | t.not(key, 'toString'); 235 | return [key, value]; 236 | }, {deep: true}); 237 | 238 | t.is(result.matcher, jestMatcher); 239 | t.is(result.normal.nested, 'value'); 240 | t.is(calls, 3); // 'normal', 'nested', 'matcher' 241 | }); 242 | 243 | test('options object is not mutated', t => { 244 | const options = {deep: true, target: {}}; 245 | const originalOptions = {...options}; 246 | 247 | mapObject({a: 1}, (key, value) => [key, value], options); 248 | 249 | t.deepEqual(options, originalOptions); 250 | }); 251 | 252 | test('built-in objects are not recursed into', t => { 253 | const date = new Date(); 254 | const regex = /test/; 255 | const error = new Error('test'); 256 | const map = new Map([['key', 'value']]); 257 | const set = new Set([1, 2, 3]); 258 | const promise = Promise.resolve(42); 259 | const buffer = new ArrayBuffer(8); 260 | const uint8Array = new Uint8Array(4); 261 | 262 | const input = { 263 | date, 264 | regex, 265 | error, 266 | map, 267 | set, 268 | promise, 269 | buffer, 270 | uint8Array, 271 | normal: {nested: 'value'}, 272 | }; 273 | 274 | const calls = []; 275 | mapObject(input, (key, value) => { 276 | calls.push(key); 277 | return [key, value]; 278 | }, {deep: true}); 279 | 280 | // Should visit top-level keys and nested normal object 281 | t.deepEqual(calls.sort(), ['buffer', 'date', 'error', 'map', 'nested', 'normal', 'promise', 'regex', 'set', 'uint8Array']); 282 | }); 283 | 284 | test('symbol keys are ignored by default', t => { 285 | const symbol = Symbol('test'); 286 | const input = { 287 | regular: 'value', 288 | [symbol]: 'symbolValue', 289 | }; 290 | 291 | const result = mapObject(input, (key, value) => [key, value]); 292 | 293 | t.true('regular' in result); 294 | t.false(symbol in result); 295 | t.is(result.regular, 'value'); 296 | t.is(result[symbol], undefined); 297 | }); 298 | 299 | test('symbol keys are included with includeSymbols option', t => { 300 | const symbol = Symbol('test'); 301 | const input = { 302 | regular: 'value', 303 | [symbol]: 'symbolValue', 304 | }; 305 | 306 | const result = mapObject(input, (key, value) => [key, value], {includeSymbols: true}); 307 | 308 | t.true('regular' in result); 309 | t.true(symbol in result); 310 | t.is(result.regular, 'value'); 311 | t.is(result[symbol], 'symbolValue'); 312 | }); 313 | 314 | test('symbol keys work with deep option', t => { 315 | const symbol1 = Symbol('outer'); 316 | const symbol2 = Symbol('inner'); 317 | const input = { 318 | regular: 'value', 319 | nested: { 320 | regularNested: 'nestedValue', 321 | [symbol2]: 'innerSymbol', 322 | }, 323 | [symbol1]: 'outerSymbol', 324 | }; 325 | 326 | const result = mapObject(input, (key, value) => [key, value], { 327 | deep: true, 328 | includeSymbols: true, 329 | }); 330 | 331 | t.is(result.regular, 'value'); 332 | t.is(result.nested.regularNested, 'nestedValue'); 333 | t.is(result.nested[symbol2], 'innerSymbol'); 334 | t.is(result[symbol1], 'outerSymbol'); 335 | }); 336 | 337 | test('handles invalid mapper return values gracefully', t => { 338 | t.throws(() => { 339 | mapObject({a: 1}, () => null); 340 | }, { 341 | instanceOf: TypeError, 342 | message: 'Mapper must return an array or mapObjectSkip, got null', 343 | }); 344 | 345 | t.throws(() => { 346 | mapObject({a: 1}, () => 'string'); 347 | }, { 348 | instanceOf: TypeError, 349 | message: 'Mapper must return an array or mapObjectSkip, got string', 350 | }); 351 | 352 | t.throws(() => { 353 | mapObject({a: 1}, () => []); 354 | }, { 355 | instanceOf: TypeError, 356 | message: 'Mapper must return an array with at least 2 elements [key, value], got 0 elements', 357 | }); 358 | 359 | t.throws(() => { 360 | mapObject({a: 1}, () => ['key']); 361 | }, { 362 | instanceOf: TypeError, 363 | message: 'Mapper must return an array with at least 2 elements [key, value], got 1 elements', 364 | }); 365 | }); 366 | 367 | test('handles non-configurable target properties gracefully', t => { 368 | const target = {}; 369 | Object.defineProperty(target, 'readonly', { 370 | value: 'original', 371 | writable: false, 372 | configurable: false, 373 | }); 374 | 375 | const result = mapObject({readonly: 'new', other: 'value'}, (key, value) => [key, value], {target}); 376 | 377 | t.is(result, target); 378 | t.is(result.readonly, 'original'); // Should remain unchanged 379 | t.is(result.other, 'value'); // Should be added 380 | }); 381 | 382 | test('handles property key collisions', t => { 383 | const result = mapObject({a: 1, b: 2}, (key, value) => ['same', value]); 384 | 385 | // Last value wins 386 | t.deepEqual(result, {same: 2}); 387 | t.is(Object.keys(result).length, 1); 388 | }); 389 | --------------------------------------------------------------------------------