├── .npmrc ├── .gitattributes ├── .gitignore ├── .github ├── security.md └── workflows │ └── main.yml ├── .editorconfig ├── package.json ├── license ├── index.test-d.ts ├── index.js ├── index.d.ts ├── readme.md └── 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 | - 22 15 | - 20 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sort-keys", 3 | "version": "6.0.0", 4 | "description": "Sort the keys of an object", 5 | "license": "MIT", 6 | "repository": "sindresorhus/sort-keys", 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 | "sort", 31 | "object", 32 | "keys", 33 | "key", 34 | "stable", 35 | "deterministic", 36 | "deep", 37 | "recursive", 38 | "recursively", 39 | "array", 40 | "sorted", 41 | "sorting" 42 | ], 43 | "dependencies": { 44 | "is-plain-obj": "^4.1.0" 45 | }, 46 | "devDependencies": { 47 | "ava": "^6.4.1", 48 | "tsd": "^0.33.0", 49 | "xo": "^1.2.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /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} from 'tsd'; 2 | import sortKeys, {type Options, type SortContext} from './index.js'; 3 | 4 | // Test basic functionality 5 | expectType<{a: 0; b: 0; c: 0}>(sortKeys({c: 0, a: 0, b: 0})); 6 | 7 | // Test deep with boolean 8 | expectType<{a: 0; b: {a: 0; b: 0}}>(sortKeys({b: {b: 0, a: 0}, a: 0}, {deep: true})); 9 | 10 | // Test deep with function 11 | expectType<{a: 0; b: {a: 0; b: 0}}>(sortKeys({b: {b: 0, a: 0}, a: 0}, { 12 | deep(context) { 13 | expectType(context); 14 | expectType(context.key); 15 | expectType(context.value); 16 | expectType(context.path); 17 | expectType(context.depth); 18 | return context.depth < 3; 19 | }, 20 | })); 21 | 22 | // Test ignoreKeys with array 23 | expectType<{_private: 1; a: 0; b: 0; c: 0}>(sortKeys({ 24 | c: 0, a: 0, _private: 1, b: 0, 25 | }, {ignoreKeys: ['_private']})); 26 | 27 | // Test ignoreKeys with function 28 | expectType<{_temp: 1; a: 0; b: 0; c: 0}>(sortKeys({ 29 | c: 0, a: 0, _temp: 1, b: 0, 30 | }, { 31 | ignoreKeys(context) { 32 | expectType(context); 33 | return context.key.startsWith('_'); 34 | }, 35 | })); 36 | 37 | // Test arrays 38 | expectType>(sortKeys([{a: 0, b: 0}, {b: 0, a: 0}], {deep: true})); 39 | 40 | // Test custom compare 41 | expectType<{c: 0; b: 0; a: 0}>(sortKeys( 42 | {c: 0, a: 0, b: 0}, 43 | { 44 | compare(left, right) { 45 | expectType(left); 46 | expectType(right); 47 | return -left.localeCompare(right); 48 | }, 49 | }, 50 | )); 51 | 52 | // Test options type 53 | const options: Options = { 54 | deep: ({path, depth}) => depth < 3 && !path.includes('config'), 55 | ignoreKeys: ({key}) => key.startsWith('_'), 56 | compare: (a, b) => a.localeCompare(b), 57 | }; 58 | 59 | expectType(options); 60 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import isPlainObject from 'is-plain-obj'; 2 | 3 | // Build a new path array for the current key. 4 | function buildPath(parentPathArray, key) { 5 | if (Array.isArray(parentPathArray) && parentPathArray.length > 0) { 6 | return [...parentPathArray, key]; 7 | } 8 | 9 | return [key]; 10 | } 11 | 12 | export default function sortKeys(object, options = {}) { 13 | if (!isPlainObject(object) && !Array.isArray(object)) { 14 | throw new TypeError('Expected a plain object or array'); 15 | } 16 | 17 | const {deep = false, compare, ignoreKeys} = options; 18 | const cache = new WeakMap(); 19 | 20 | // Check if a key should be ignored based on ignoreKeys option 21 | const shouldIgnoreKey = context => { 22 | if (Array.isArray(ignoreKeys)) { 23 | return ignoreKeys.includes(context.key); 24 | } 25 | 26 | if (typeof ignoreKeys === 'function') { 27 | return ignoreKeys(context); 28 | } 29 | 30 | return false; 31 | }; 32 | 33 | // Check if deep processing should be applied 34 | const shouldProcessDeep = context => { 35 | if (typeof deep === 'boolean') { 36 | return deep; 37 | } 38 | 39 | if (typeof deep === 'function') { 40 | return deep(context); 41 | } 42 | 43 | return false; 44 | }; 45 | 46 | // Deep sort an array 47 | const deepSortArray = (array, currentPath, currentDepth) => { 48 | const resultFromCache = cache.get(array); 49 | if (resultFromCache !== undefined) { 50 | return resultFromCache; 51 | } 52 | 53 | const result = []; 54 | result.length = array.length; // Preserve sparseness 55 | cache.set(array, result); 56 | 57 | for (const index of array.keys()) { 58 | if (!(index in array)) { 59 | continue; // Preserve holes in sparse arrays 60 | } 61 | 62 | const item = array[index]; 63 | const indexKey = String(index); 64 | const itemPath = buildPath(currentPath, indexKey); 65 | const context = { 66 | key: indexKey, 67 | value: item, 68 | path: itemPath, 69 | depth: currentDepth + 1, 70 | }; 71 | 72 | if (Array.isArray(item)) { 73 | result[index] = shouldProcessDeep(context) 74 | ? deepSortArray(item, itemPath, currentDepth + 1) 75 | : item; 76 | continue; 77 | } 78 | 79 | if (isPlainObject(item)) { 80 | result[index] = shouldProcessDeep(context) 81 | ? _sortKeys(item, itemPath, currentDepth + 1) 82 | : item; 83 | continue; 84 | } 85 | 86 | result[index] = item; 87 | } 88 | 89 | return result; 90 | }; 91 | 92 | // Sort keys of an object 93 | const _sortKeys = (object, currentPath = [], currentDepth = 0) => { 94 | const resultFromCache = cache.get(object); 95 | if (resultFromCache !== undefined) { 96 | return resultFromCache; 97 | } 98 | 99 | const result = {}; 100 | const allKeys = Object.keys(object); 101 | 102 | // Separate ignored and non-ignored keys 103 | const ignoredKeys = []; 104 | const keysToSort = []; 105 | 106 | for (const key of allKeys) { 107 | const value = object[key]; 108 | const keyPath = buildPath(currentPath, key); 109 | const context = { 110 | key, 111 | value, 112 | path: keyPath, 113 | depth: currentDepth, 114 | }; 115 | 116 | if (shouldIgnoreKey(context)) { 117 | ignoredKeys.push(key); 118 | } else { 119 | keysToSort.push(key); 120 | } 121 | } 122 | 123 | // Sort only the non-ignored keys 124 | const sortedKeys = keysToSort.sort(compare); 125 | 126 | // Combine ignored keys (in original order) with sorted keys 127 | const finalKeys = [...ignoredKeys, ...sortedKeys]; 128 | 129 | cache.set(object, result); 130 | 131 | for (const key of finalKeys) { 132 | const value = object[key]; 133 | const keyPath = buildPath(currentPath, key); 134 | const context = { 135 | key, 136 | value, 137 | path: keyPath, 138 | depth: currentDepth, 139 | }; 140 | 141 | let newValue = value; 142 | 143 | // Only process deeply if shouldProcessDeep returns true 144 | if (shouldProcessDeep(context)) { 145 | if (Array.isArray(value)) { 146 | newValue = deepSortArray(value, keyPath, currentDepth); 147 | } else if (isPlainObject(value)) { 148 | newValue = _sortKeys(value, keyPath, currentDepth + 1); 149 | } 150 | } 151 | 152 | const descriptor = Object.getOwnPropertyDescriptor(object, key); 153 | if (descriptor.get || descriptor.set) { 154 | // Accessor descriptor: preserve as-is 155 | Object.defineProperty(result, key, descriptor); 156 | } else { 157 | // Data descriptor: carry over attributes but replace the value 158 | Object.defineProperty(result, key, { 159 | ...descriptor, 160 | value: newValue, 161 | }); 162 | } 163 | } 164 | 165 | return result; 166 | }; 167 | 168 | // Handle top-level arrays 169 | if (Array.isArray(object)) { 170 | // Always route through deepSortArray to preserve holes and avoid copying extra props. 171 | // shouldProcessDeep() will govern recursion for boolean or function `deep`. 172 | return deepSortArray(object, [], -1); 173 | } 174 | 175 | return _sortKeys(object, [], 0); 176 | } 177 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Context information passed to filter functions. 3 | */ 4 | export type SortContext = { 5 | /** 6 | The current key being processed. 7 | */ 8 | readonly key: string; 9 | 10 | /** 11 | The value associated with the current key 12 | */ 13 | readonly value: unknown; 14 | 15 | /** 16 | The full path to this key as an array of path elements (for example, `['user', 'profile', 'name']`). 17 | 18 | Array indices are stringified (for example, `['items', '0', 'title']`). 19 | 20 | Examples of generated paths (with depths): 21 | - ['user'] (depth: 0) 22 | - ['user', 'profile'] (depth: 1) 23 | - ['user', 'profile', 'name'] (depth: 2) 24 | - ['items'] (depth: 0) 25 | - ['items', '0'] (depth: 1) 26 | - ['items', '0', 'title'] (depth: 2) 27 | - ['items', '1'] (depth: 1) 28 | - ['items', '1', 'title'] (depth: 2) 29 | */ 30 | readonly path: readonly string[]; 31 | 32 | /** 33 | The current nesting depth (0 for root level). 34 | */ 35 | readonly depth: number; 36 | }; 37 | 38 | export type Options = { 39 | /** 40 | Compare function for sorting keys. 41 | 42 | @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort 43 | 44 | If omitted, remaining keys are sorted using the platform's default string sort. 45 | */ 46 | readonly compare?: (left: string, right: string) => number; 47 | 48 | /** 49 | Recursively sort keys, including keys of objects inside arrays. 50 | 51 | @default false 52 | 53 | Only plain objects are sorted; other object types are left as-is. For arrays, deep processing applies to their elements. 54 | 55 | When a boolean: 56 | - `true`: Deep process all nested objects and arrays. 57 | - `false`: Only sort keys at the current level. 58 | 59 | When a function, it receives a context object and should return `true` to enable deep processing for that specific key-value pair. 60 | The context is `SortContext` with `{ key, value, path, depth }`. 61 | 62 | @example 63 | ``` 64 | // Fine-grained deep control with context 65 | sortKeys(data, { 66 | deep: ({key, value, path, depth}) => { 67 | // Only deep process up to 2 levels 68 | if (depth >= 2) { 69 | return false; 70 | } 71 | 72 | // Skip deep processing of large arrays for performance 73 | if (Array.isArray(value) && (value as any[]).length > 100) { 74 | return false; 75 | } 76 | 77 | // Skip config objects entirely 78 | if (path.includes('config')) { 79 | return false; 80 | } 81 | 82 | return true; 83 | } 84 | }); 85 | ``` 86 | */ 87 | readonly deep?: boolean | ((context: SortContext) => boolean); 88 | 89 | /** 90 | Keys to ignore during sorting. Ignored keys appear first in their original order, followed by the sorted keys. Remaining keys are sorted by `compare`, or by default string sort if `compare` is not provided. 91 | 92 | @default [] 93 | 94 | Only affects the ordering of object keys; it does not control deep processing, and array indices are not sorted or filtered. 95 | 96 | Can be an array of key names, or a function that receives context and returns true to ignore the key. 97 | 98 | @example 99 | ``` 100 | // Ignore by name; ignored keys keep original order and appear first 101 | sortKeys({c: 0, _private: 1, a: 0, b: 0}, {ignoreKeys: ['_private']}); 102 | //=> {_private: 1, a: 0, b: 0, c: 0} 103 | 104 | // Ignore by function with multiple conditions 105 | sortKeys(data, { 106 | ignoreKeys: ({key, value, path, depth}) => { 107 | // Ignore private keys at root level 108 | if (key.startsWith('_') && depth === 0) { 109 | return true; 110 | } 111 | 112 | // Ignore metadata keys in user objects 113 | if (path[0] === 'user' && key === 'metadata') { 114 | return true; 115 | } 116 | 117 | // Ignore empty objects 118 | if (typeof value === 'object' && Object.keys(value as any).length === 0) { 119 | return true; 120 | } 121 | 122 | return false; 123 | } 124 | }); 125 | ``` 126 | */ 127 | readonly ignoreKeys?: readonly string[] | ((context: SortContext) => boolean); 128 | }; 129 | 130 | /** 131 | Sort the keys of an object. 132 | 133 | @param object - The object or array to sort. 134 | @returns A new object with sorted keys. 135 | 136 | Property descriptors are preserved, including accessors (get/set); getters are not invoked or deep-processed. Circular references are supported and preserved. 137 | 138 | When it's an object: 139 | - Only plain objects are deeply processed. 140 | - Only enumerable own string keys are considered; symbol and non-enumerable properties are ignored. 141 | 142 | When it's an array: 143 | - Array order is unchanged; holes in sparse arrays are preserved. 144 | - Elements may be deep-processed if `deep` enables it. 145 | - Extra enumerable properties on arrays are ignored. 146 | 147 | @example 148 | import sortKeys from 'sort-keys'; 149 | 150 | // Basic usage 151 | sortKeys({c: 0, a: 0, b: 0}); 152 | //=> {a: 0, b: 0, c: 0} 153 | 154 | // Deep sorting of nested objects 155 | sortKeys({b: {b: 0, a: 0}, a: 0}, {deep: true}); 156 | //=> {a: 0, b: {a: 0, b: 0}} 157 | 158 | // Deep sorting of objects inside arrays 159 | sortKeys({b: [{b: 0, a: 0}], a: 0}, {deep: true}); 160 | //=> {a: 0, b: [{a: 0, b: 0}]} 161 | 162 | // Custom key compare (reverse alphabetical) 163 | sortKeys({c: 0, a: 0, b: 0}, { 164 | compare: (a, b) => -a.localeCompare(b) 165 | }); 166 | //=> {c: 0, b: 0, a: 0} 167 | 168 | // Deep processing for a top-level array 169 | sortKeys([{b: 0, a: 2}], {deep: true}); 170 | //=> [{a: 2, b: 0}] 171 | */ 172 | export default function sortKeys>( 173 | object: T, 174 | options?: Options 175 | ): T; 176 | export default function sortKeys( 177 | object: T[], 178 | options?: Options 179 | ): T[]; 180 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # sort-keys 2 | 3 | > Sort the keys of an object 4 | 5 | Useful to get a deterministically ordered object, as the order of keys can vary between engines. 6 | 7 | ## Install 8 | 9 | ```sh 10 | npm install sort-keys 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | import sortKeys from 'sort-keys'; 17 | 18 | sortKeys({c: 0, a: 0, b: 0}); 19 | //=> {a: 0, b: 0, c: 0} 20 | 21 | sortKeys({b: {b: 0, a: 0}, a: 0}, {deep: true}); 22 | //=> {a: 0, b: {a: 0, b: 0}} 23 | 24 | sortKeys({b: [{b: 0, a: 0}], a: 0}, {deep: true}); 25 | //=> {a: 0, b: [{a: 0, b: 0}]} 26 | 27 | sortKeys({c: 0, a: 0, b: 0}, { 28 | compare: (a, b) => -a.localeCompare(b) 29 | }); 30 | //=> {c: 0, b: 0, a: 0} 31 | 32 | sortKeys([{b: 0, a: 2}], {deep: true}); 33 | //=> [{a: 2, b: 0}] 34 | ``` 35 | 36 | ## Advanced Usage with context 37 | 38 | The `deep` and `ignoreKeys` options can receive a context object with detailed information about the current key being processed: 39 | 40 | ```js 41 | // Ignore private keys only at root level 42 | sortKeys(data, { 43 | ignoreKeys: ({key, depth}) => key.startsWith('_') && depth === 0 44 | }); 45 | 46 | // Deep process only up to 3 levels, skip config paths 47 | sortKeys(data, { 48 | deep: ({path, depth}) => depth < 3 && !path.includes('config') 49 | }); 50 | 51 | // Complex path-based logic 52 | sortKeys(data, { 53 | deep: ({path}) => !(path[0] === 'user' && path[1] === 'cache'), 54 | ignoreKeys: ({path, key}) => (path.length === 1 && path[0] === 'metadata') || key.startsWith('_') 55 | }); 56 | ``` 57 | 58 | ## API 59 | 60 | ### sortKeys(object, options?) 61 | 62 | Returns a new object with sorted keys. 63 | 64 | Property descriptors are preserved, including accessors (get/set), and getters are not invoked or deep-processed. Circular references are supported and preserved. 65 | 66 | #### object 67 | 68 | Type: `object | Array` 69 | 70 | When it's an object: 71 | - Only plain objects are deeply processed. 72 | - Only enumerable own string keys are considered; symbol and non-enumerable properties are ignored. 73 | 74 | When it's an array: 75 | - Array order is unchanged; holes in sparse arrays are preserved. 76 | - Elements may be deep-processed if `deep` enables it. 77 | - Extra enumerable properties on arrays are ignored. 78 | 79 | #### options 80 | 81 | Type: `object` 82 | 83 | ##### compare 84 | 85 | Type: `Function` 86 | 87 | [Compare function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) for sorting keys. 88 | 89 | If omitted, remaining keys are sorted using the platform's default string sort. 90 | 91 | ```js 92 | sortKeys(object, { 93 | compare: (a, b) => b.localeCompare(a) // Reverse alphabetical 94 | }); 95 | ``` 96 | 97 | ##### deep 98 | 99 | Type: `boolean | Function`\ 100 | Default: `false` 101 | 102 | Recursively sort keys, including keys of objects inside arrays. 103 | 104 | Only plain objects are sorted; other object types are left as-is. For arrays, deep processing applies to their elements. 105 | 106 | When a boolean: 107 | - `true`: Deep process all nested objects and arrays. 108 | - `false`: Only sort keys at the current level. 109 | 110 | When a function, it receives a context object and should return `true` to enable deep processing for that specific key-value pair: 111 | 112 | ```js 113 | sortKeys(data, { 114 | deep: ({key, value, path, depth}) => { 115 | // Only deep process up to 2 levels 116 | if (depth >= 2) { 117 | return false; 118 | } 119 | 120 | // Skip deep processing of large arrays for performance 121 | if (Array.isArray(value) && value.length > 100) { 122 | return false; 123 | } 124 | 125 | // Skip config objects entirely 126 | if (path.includes('config')) { 127 | return false; 128 | } 129 | 130 | return true; 131 | } 132 | }); 133 | ``` 134 | 135 | ##### ignoreKeys 136 | 137 | Type: `string[] | Function`\ 138 | Default: `[]` 139 | 140 | Keys to ignore when sorting. Ignored keys will appear first in their original order, followed by the sorted keys. Remaining keys are sorted by `compare`, or by default string sort if `compare` is not provided. 141 | 142 | Can be an array of key names: 143 | 144 | ```js 145 | sortKeys({c: 0, _private: 1, a: 0, b: 0}, {ignoreKeys: ['_private']}); 146 | //=> {_private: 1, a: 0, b: 0, c: 0} 147 | ``` 148 | 149 | Or a function that receives a context object: 150 | 151 | ```js 152 | sortKeys(data, { 153 | ignoreKeys: ({key, value, path, depth}) => { 154 | // Ignore private keys at root level 155 | if (key.startsWith('_') && depth === 0) { 156 | return true; 157 | } 158 | 159 | // Ignore metadata keys in user objects 160 | if (path[0] === 'user' && key === 'metadata') { 161 | return true; 162 | } 163 | 164 | // Ignore empty objects 165 | if (typeof value === 'object' && Object.keys(value).length === 0) { 166 | return true; 167 | } 168 | 169 | return false; 170 | } 171 | }); 172 | ``` 173 | 174 | **Note**: `ignoreKeys` only affects the ordering of object keys; it does not control deep processing, and array indices are not sorted or filtered. To prevent deep processing of specific values, use the `deep` function option. 175 | 176 | #### Context object 177 | 178 | When using functions for `deep` or `ignoreKeys`, they receive a context object with: 179 | 180 | - **`key`** (`string`): The current key being processed. 181 | - **`value`** (`any`): The value associated with the current key. 182 | - **`path`** (`string[]`): The full path to this key as an array of elements (for example, `['user', 'profile', 'name']`). Array indices are stringified (for example, `['items', '0', 'title']`). 183 | - **`depth`** (`number`): The current nesting depth (0 for root level). 184 | 185 | #### Path examples 186 | 187 | ```js 188 | const data = { 189 | user: { 190 | profile: { 191 | name: 'John' 192 | } 193 | }, 194 | items: [ 195 | {title: 'Item 1'}, 196 | {title: 'Item 2'} 197 | ] 198 | }; 199 | 200 | // Paths generated during processing (as arrays): 201 | // ['user'] (depth: 0) 202 | // ['user', 'profile'] (depth: 1) 203 | // ['user', 'profile', 'name'] (depth: 2) 204 | // ['items'] (depth: 0) 205 | // ['items', '0'] (depth: 1) 206 | // ['items', '0', 'title'] (depth: 2) 207 | // ['items', '1'] (depth: 1) 208 | // ['items', '1', 'title'] (depth: 2) 209 | ``` 210 | 211 | Note: `path` is an array of elements with stringified array indices (for example, `['items', '0', 'title']`). If your key names include dots or special characters, this representation remains unambiguous. 212 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sortKeys from './index.js'; 3 | 4 | function deepEqualInOrder(t, actual, expected) { 5 | t.deepEqual(actual, expected); 6 | 7 | const seen = new Set(); 8 | 9 | function assertSameKeysInOrder(object1, object2) { 10 | if (seen.has(object1) && seen.has(object2)) { 11 | return; 12 | } 13 | 14 | seen.add(object1); 15 | seen.add(object2); 16 | 17 | if (Array.isArray(object1)) { 18 | for (const index of object1.keys()) { 19 | assertSameKeysInOrder(object1[index], object2[index]); 20 | } 21 | } else if (typeof object1 === 'object') { 22 | const keys1 = Object.keys(object1); 23 | const keys2 = Object.keys(object2); 24 | t.deepEqual(keys1, keys2); 25 | for (const index of keys1.keys()) { 26 | assertSameKeysInOrder(object1[keys1[index]], object2[keys2[index]]); 27 | } 28 | } 29 | } 30 | 31 | assertSameKeysInOrder(actual, expected); 32 | } 33 | 34 | // Original tests 35 | test('sort the keys of an object', t => { 36 | deepEqualInOrder(t, sortKeys({c: 0, a: 0, b: 0}), {a: 0, b: 0, c: 0}); 37 | }); 38 | 39 | test('custom compare function', t => { 40 | const compare = (a, b) => b.localeCompare(a); 41 | deepEqualInOrder(t, sortKeys({c: 0, a: 0, b: 0}, {compare}), {c: 0, b: 0, a: 0}); 42 | }); 43 | 44 | test('deep option as boolean', t => { 45 | deepEqualInOrder(t, sortKeys({c: {c: 0, a: 0, b: 0}, a: 0, b: 0}, {deep: true}), {a: 0, b: 0, c: {a: 0, b: 0, c: 0}}); 46 | 47 | t.notThrows(() => { 48 | const object = {a: 0}; 49 | object.circular = object; 50 | sortKeys(object, {deep: true}); 51 | }); 52 | 53 | const object = {z: 0}; 54 | object.circular = object; 55 | const sortedObject = sortKeys(object, {deep: true}); 56 | 57 | t.is(sortedObject, sortedObject.circular); 58 | t.deepEqual(Object.keys(sortedObject), ['circular', 'z']); 59 | 60 | const object1 = {b: 0}; 61 | const object2 = {d: 0}; 62 | const object3 = {a: [{b: 0}]}; 63 | const object4 = {a: [{d: 0}]}; 64 | 65 | object1.a = object2; 66 | object2.c = object1; 67 | object3.a[0].a = object4.a[0]; 68 | object4.a[0].c = object3.a[0]; 69 | 70 | t.notThrows(() => { 71 | sortKeys(object1, {deep: true}); 72 | sortKeys(object2, {deep: true}); 73 | sortKeys(object3, {deep: true}); 74 | sortKeys(object4, {deep: true}); 75 | }); 76 | 77 | const sorted = sortKeys(object1, {deep: true}); 78 | const deepSorted = sortKeys(object3, {deep: true}); 79 | 80 | t.is(sorted, sorted.a.c); 81 | deepEqualInOrder(t, deepSorted.a[0], deepSorted.a[0].a.c); 82 | t.deepEqual(Object.keys(sorted), ['a', 'b']); 83 | t.deepEqual(Object.keys(deepSorted.a[0]), ['a', 'b']); 84 | deepEqualInOrder(t, sortKeys({ 85 | c: {c: 0, a: 0, b: 0}, a: 0, b: 0, z: [9, 8, 7, 6, 5], 86 | }, {deep: true}), { 87 | a: 0, b: 0, c: {a: 0, b: 0, c: 0}, z: [9, 8, 7, 6, 5], 88 | }); 89 | t.deepEqual(Object.keys(sortKeys({a: [{b: 0, a: 0}]}, {deep: true}).a[0]), ['a', 'b']); 90 | }); 91 | 92 | test('deep arrays', t => { 93 | const object = { 94 | b: 0, 95 | a: [ 96 | {b: 0, a: 0}, 97 | [{b: 0, a: 0}], 98 | ], 99 | }; 100 | object.a.push(object); 101 | object.a[1].push(object.a[1]); 102 | 103 | t.notThrows(() => { 104 | sortKeys(object, {deep: true}); 105 | }); 106 | 107 | const sorted = sortKeys(object, {deep: true}); 108 | t.is(sorted.a[2], sorted); 109 | t.is(sorted.a[1][1], sorted.a[1]); 110 | t.deepEqual(Object.keys(sorted), ['a', 'b']); 111 | t.deepEqual(Object.keys(sorted.a[0]), ['a', 'b']); 112 | t.deepEqual(Object.keys(sorted.a[1][0]), ['a', 'b']); 113 | }); 114 | 115 | test('top-level array', t => { 116 | const array = [{b: 0, a: 0}, {c: 0, d: 0}]; 117 | const sorted = sortKeys(array); 118 | t.not(sorted, array, 'should make a copy'); 119 | t.is(sorted[0], array[0]); 120 | t.is(sorted[1], array[1]); 121 | 122 | const deepSorted = sortKeys(array, {deep: true}); 123 | t.not(deepSorted, array); 124 | t.not(deepSorted[0], array[0]); 125 | t.not(deepSorted[1], array[1]); 126 | t.deepEqual(Object.keys(deepSorted[0]), ['a', 'b']); 127 | t.deepEqual(Object.keys(deepSorted[1]), ['c', 'd']); 128 | }); 129 | 130 | test('top-level array preserves holes when shallow', t => { 131 | const array = []; 132 | array.length = 3; // Create holes 133 | array[1] = {b: 0, a: 0}; 134 | 135 | const sorted = sortKeys(array); // Shallow copy 136 | 137 | // Holes preserved 138 | t.false(0 in sorted); 139 | t.true(1 in sorted); 140 | t.false(2 in sorted); 141 | // Element identity preserved 142 | t.is(sorted[1], array[1]); 143 | }); 144 | 145 | test('keeps property descriptors intact', t => { 146 | const descriptors = { 147 | b: { 148 | value: 1, 149 | configurable: true, 150 | enumerable: true, 151 | writable: false, 152 | }, 153 | a: { 154 | value: 2, 155 | configurable: false, 156 | enumerable: true, 157 | writable: true, 158 | }, 159 | }; 160 | 161 | const object = {}; 162 | Object.defineProperties(object, descriptors); 163 | 164 | const sorted = sortKeys(object); 165 | 166 | deepEqualInOrder(t, sorted, {a: 2, b: 1}); 167 | t.deepEqual(Object.getOwnPropertyDescriptors(sorted), descriptors); 168 | }); 169 | 170 | // New tests for context-based API 171 | test('ignoreKeys as array', t => { 172 | const object = { 173 | c: 0, _private: 1, a: 0, b: 0, __internal: 2, 174 | }; 175 | const sorted = sortKeys(object, {ignoreKeys: ['_private', '__internal']}); 176 | 177 | deepEqualInOrder(t, sorted, { 178 | _private: 1, __internal: 2, a: 0, b: 0, c: 0, 179 | }); 180 | }); 181 | 182 | test('ignoreKeys as function with context', t => { 183 | const object = { 184 | c: 0, _temp: 1, a: 0, $special: 2, b: 0, 185 | }; 186 | const sorted = sortKeys(object, { 187 | ignoreKeys: ({key, depth}) => (key.startsWith('_') || key.startsWith('$')) && depth === 0, 188 | }); 189 | 190 | deepEqualInOrder(t, sorted, { 191 | _temp: 1, $special: 2, a: 0, b: 0, c: 0, 192 | }); 193 | }); 194 | 195 | test('deep as function with context', t => { 196 | const object = { 197 | c: {z: 0, a: 0, b: 0}, 198 | a: 0, 199 | config: {z: 0, a: 0, b: 0}, 200 | }; 201 | 202 | const sorted = sortKeys(object, { 203 | deep: ({path}) => !path.includes('config'), 204 | }); 205 | 206 | deepEqualInOrder(t, sorted, { 207 | a: 0, 208 | c: {a: 0, b: 0, z: 0}, 209 | config: {z: 0, a: 0, b: 0}, // Not deep processed 210 | }); 211 | }); 212 | 213 | test('depth-based deep processing', t => { 214 | const object = { 215 | c: { 216 | nested: { 217 | deep: {z: 0, a: 0}, 218 | }, 219 | b: 0, 220 | a: 0, 221 | }, 222 | a: 0, 223 | }; 224 | 225 | const sorted = sortKeys(object, { 226 | deep: ({depth}) => depth < 2, // Only process up to depth 1 227 | }); 228 | 229 | deepEqualInOrder(t, sorted, { 230 | a: 0, 231 | c: { 232 | a: 0, 233 | b: 0, 234 | nested: { 235 | deep: {z: 0, a: 0}, // Not deep processed due to depth limit 236 | }, 237 | }, 238 | }); 239 | }); 240 | 241 | test('path-based ignoreKeys', t => { 242 | const object = { 243 | user: { 244 | metadata: {z: 0, a: 0}, 245 | profile: {z: 0, a: 0}, 246 | }, 247 | c: 0, 248 | a: 0, 249 | }; 250 | 251 | const sorted = sortKeys(object, { 252 | deep: true, 253 | ignoreKeys: ({key, path}) => key === 'metadata' && path.length === 2 && path[0] === 'user' && path[1] === 'metadata', 254 | }); 255 | 256 | // Metadata key is ignored from sorting (stays in original position) 257 | // but its VALUE is still deep processed because deep: true 258 | deepEqualInOrder(t, sorted, { 259 | a: 0, 260 | c: 0, 261 | user: { 262 | metadata: {a: 0, z: 0}, // Deep processed (keys sorted) 263 | profile: {a: 0, z: 0}, // Deep processed (keys sorted) 264 | }, 265 | }); 266 | }); 267 | 268 | test('ignoreKeys with no deep processing', t => { 269 | const object = { 270 | user: { 271 | metadata: {z: 0, a: 0}, 272 | profile: {z: 0, a: 0}, 273 | }, 274 | c: 0, 275 | a: 0, 276 | }; 277 | 278 | // To keep metadata contents unsorted, use deep function instead 279 | const sorted = sortKeys(object, { 280 | deep: ({path}) => !(path.length === 2 && path[0] === 'user' && path[1] === 'metadata'), 281 | ignoreKeys: ({key, path}) => key === 'metadata' && path.length === 2 && path[0] === 'user' && path[1] === 'metadata', 282 | }); 283 | 284 | deepEqualInOrder(t, sorted, { 285 | a: 0, 286 | c: 0, 287 | user: { 288 | metadata: {z: 0, a: 0}, // NOT deep processed due to deep filter 289 | profile: {a: 0, z: 0}, // Deep processed 290 | }, 291 | }); 292 | }); 293 | 294 | test('complex context-based processing', t => { 295 | const object = { 296 | _private: {z: 0, a: 0}, 297 | config: { 298 | nested: {z: 0, a: 0}, 299 | b: 0, 300 | a: 0, 301 | }, 302 | data: {z: 0, a: 0}, 303 | c: 0, 304 | a: 0, 305 | }; 306 | 307 | const sorted = sortKeys(object, { 308 | deep({path, depth, key}) { 309 | // Don't deep process private objects or deeply nested config 310 | if (key.startsWith('_')) { 311 | return false; 312 | } 313 | 314 | if (path.length >= 2 && path[0] === 'config' && path[1] === 'nested') { 315 | return false; 316 | } 317 | 318 | return depth < 3; 319 | }, 320 | ignoreKeys: ({key, depth}) => key.startsWith('_') && depth === 0, 321 | }); 322 | 323 | deepEqualInOrder(t, sorted, { 324 | _private: {z: 0, a: 0}, // Ignored key AND not deep processed 325 | a: 0, 326 | c: 0, 327 | config: { 328 | a: 0, 329 | b: 0, 330 | nested: {z: 0, a: 0}, // Not deep processed due to path filter 331 | }, 332 | data: {a: 0, z: 0}, // Deep processed 333 | }); 334 | }); 335 | 336 | test('array processing with context', t => { 337 | const object = { 338 | users: [ 339 | {z: 0, a: 0, profile: {z: 0, a: 0}}, 340 | {z: 0, a: 0}, 341 | ], 342 | b: 0, 343 | a: 0, 344 | }; 345 | 346 | const sorted = sortKeys(object, { 347 | deep: ({path}) => !(path.length === 3 && path[0] === 'users' && path[1] === '0' && path[2] === 'profile'), // Skip deep processing of first user's profile 348 | }); 349 | 350 | deepEqualInOrder(t, sorted, { 351 | a: 0, 352 | b: 0, 353 | users: [ 354 | {a: 0, profile: {z: 0, a: 0}, z: 0}, // Profile not deep processed 355 | {a: 0, z: 0}, // Deep processed 356 | ], 357 | }); 358 | }); 359 | 360 | test('performance: large depth with depth limit', t => { 361 | // Create deeply nested object 362 | let deep = {z: 0, a: 0}; 363 | for (let i = 0; i < 10; i++) { 364 | deep = {nested: deep, z: 0, a: 0}; 365 | } 366 | 367 | const object = {c: 0, data: deep, a: 0}; 368 | 369 | const sorted = sortKeys(object, { 370 | deep: ({depth}) => depth < 3, // Limit depth to prevent excessive processing 371 | }); 372 | 373 | t.is(typeof sorted, 'object'); 374 | t.deepEqual(Object.keys(sorted), ['a', 'c', 'data']); 375 | }); 376 | 377 | test('keeps property descriptors intact with context', t => { 378 | const descriptors = { 379 | b: { 380 | value: 1, 381 | configurable: true, 382 | enumerable: true, 383 | writable: false, 384 | }, 385 | a: { 386 | value: 2, 387 | configurable: false, 388 | enumerable: true, 389 | writable: true, 390 | }, 391 | }; 392 | 393 | const object = {}; 394 | Object.defineProperties(object, descriptors); 395 | 396 | const sorted = sortKeys(object, { 397 | ignoreKeys: () => false, // Don't ignore any keys 398 | }); 399 | 400 | deepEqualInOrder(t, sorted, {a: 2, b: 1}); 401 | t.deepEqual(Object.getOwnPropertyDescriptors(sorted), descriptors); 402 | }); 403 | 404 | test('mixed ignoreKeys array and function behavior', t => { 405 | // Test that array ignoreKeys still works with new API 406 | const object = { 407 | z: 0, 408 | _private: 1, 409 | config: {z: 0, a: 0}, 410 | a: 0, 411 | __internal: 2, 412 | }; 413 | 414 | const sorted = sortKeys(object, { 415 | deep: true, 416 | ignoreKeys: ['_private', '__internal'], // Array form 417 | }); 418 | 419 | deepEqualInOrder(t, sorted, { 420 | _private: 1, 421 | __internal: 2, 422 | a: 0, 423 | config: {a: 0, z: 0}, // Deep processed 424 | z: 0, 425 | }); 426 | }); 427 | 428 | test('deep function with complex logic', t => { 429 | const object = { 430 | user: { 431 | cache: {large: Array.from({length: 1000}).fill(0).map((_, i) => ({[`key${i}`]: i}))}, 432 | profile: {name: 'John', age: 30}, 433 | settings: {theme: 'dark', language: 'en'}, 434 | }, 435 | data: {items: [{z: 0, a: 0}]}, 436 | a: 0, 437 | }; 438 | 439 | const sorted = sortKeys(object, { 440 | deep({path, depth, value}) { 441 | // Don't process cache (performance) 442 | if (path.includes('cache')) { 443 | return false; 444 | } 445 | 446 | // Don't go deeper than 3 levels 447 | if (depth >= 3) { 448 | return false; 449 | } 450 | 451 | // Don't process large arrays 452 | if (Array.isArray(value) && value.length > 100) { 453 | return false; 454 | } 455 | 456 | return true; 457 | }, 458 | }); 459 | 460 | t.deepEqual(Object.keys(sorted), ['a', 'data', 'user']); 461 | t.deepEqual(Object.keys(sorted.user), ['cache', 'profile', 'settings']); 462 | t.deepEqual(Object.keys(sorted.user.profile), ['age', 'name']); 463 | t.deepEqual(Object.keys(sorted.user.settings), ['language', 'theme']); 464 | 465 | // Cache should not be processed 466 | t.is(sorted.user.cache, object.user.cache); 467 | 468 | // Data items should be processed 469 | t.deepEqual(Object.keys(sorted.data.items[0]), ['a', 'z']); 470 | }); 471 | 472 | test('context path for arrays', t => { 473 | const object = { 474 | items: [ 475 | {z: 0, a: 0}, 476 | { 477 | nested: {z: 0, a: 0}, 478 | }, 479 | ], 480 | }; 481 | 482 | const sorted = sortKeys(object, { 483 | deep: ({path}) => !(path.length === 3 && path[0] === 'items' && path[1] === '1' && path[2] === 'nested'), // Don't deep process nested in second item 484 | }); 485 | 486 | deepEqualInOrder(t, sorted, { 487 | items: [ 488 | {a: 0, z: 0}, // Deep processed 489 | { 490 | nested: {z: 0, a: 0}, // Not deep processed 491 | }, 492 | ], 493 | }); 494 | }); 495 | 496 | test('ignoreKeys with complex path matching', t => { 497 | const object = { 498 | components: { 499 | header: {_internal: 'value', title: 'Header', visible: true}, 500 | footer: {_internal: 'value', text: 'Footer', visible: false}, 501 | }, 502 | _global: 'setting', 503 | config: {_internal: 'value', theme: 'dark'}, 504 | }; 505 | 506 | const sorted = sortKeys(object, { 507 | deep: true, 508 | ignoreKeys({key, path}) { 509 | // Ignore all _internal keys everywhere 510 | if (key === '_internal') { 511 | return true; 512 | } 513 | 514 | // Ignore _global at root 515 | if (key === '_global' && path.length === 1 && path[0] === '_global') { 516 | return true; 517 | } 518 | 519 | return false; 520 | }, 521 | }); 522 | 523 | deepEqualInOrder(t, sorted, { 524 | _global: 'setting', // Ignored at root 525 | components: { 526 | footer: { 527 | _internal: 'value', // Ignored 528 | text: 'Footer', 529 | visible: false, 530 | }, 531 | header: { 532 | _internal: 'value', // Ignored 533 | title: 'Header', 534 | visible: true, 535 | }, 536 | }, 537 | config: { 538 | _internal: 'value', // Ignored 539 | theme: 'dark', 540 | }, 541 | }); 542 | }); 543 | 544 | test('edge case: empty objects and arrays', t => { 545 | const object = { 546 | empty: {}, 547 | emptyArray: [], 548 | c: 0, 549 | a: 0, 550 | }; 551 | 552 | const sorted = sortKeys(object, { 553 | deep: true, 554 | ignoreKeys({value}) { 555 | // Check if it's an empty object or empty array 556 | if (Array.isArray(value)) { 557 | return value.length === 0; 558 | } 559 | 560 | if (typeof value === 'object' && value !== null) { 561 | return Object.keys(value).length === 0; 562 | } 563 | 564 | return false; 565 | }, 566 | }); 567 | 568 | // Empty and emptyArray stay in original position (ignored) 569 | // c and a get sorted to a, c 570 | deepEqualInOrder(t, sorted, { 571 | empty: {}, // Ignored (empty object) 572 | emptyArray: [], // Ignored (empty array) 573 | a: 0, // Sorted 574 | c: 0, // Sorted 575 | }); 576 | }); 577 | 578 | test('context validation', t => { 579 | const object = {b: {nested: 'value'}, a: 0}; 580 | const contextCalls = []; 581 | 582 | sortKeys(object, { 583 | deep(context) { 584 | // Only track calls for object values (not primitives) 585 | if (typeof context.value === 'object' && context.value !== null) { 586 | contextCalls.push({...context}); 587 | } 588 | 589 | return true; 590 | }, 591 | }); 592 | 593 | // Should have context call only for 'b' (object value) 594 | // 'nested' has primitive value so won't trigger deep processing 595 | t.is(contextCalls.length, 1); 596 | 597 | const bContext = contextCalls[0]; 598 | t.is(bContext.key, 'b'); 599 | t.deepEqual(bContext.path, ['b']); 600 | t.is(bContext.depth, 0); 601 | t.deepEqual(bContext.value, {nested: 'value'}); 602 | }); 603 | 604 | test('context validation - comprehensive', t => { 605 | const object = {b: {nested: 'value'}, a: 0}; 606 | const deepCalls = []; 607 | const ignoreKeysCalls = []; 608 | 609 | sortKeys(object, { 610 | deep(context) { 611 | deepCalls.push({...context}); 612 | return true; 613 | }, 614 | ignoreKeys(context) { 615 | ignoreKeysCalls.push({...context}); 616 | return false; // Don't ignore any keys 617 | }, 618 | }); 619 | 620 | // Should have deep calls for values that could be processed deeply 621 | // and ignoreKeys calls for all keys being sorted 622 | t.is(ignoreKeysCalls.length, 3); // 'b', 'a', 'nested' 623 | t.is(deepCalls.length, 3); // Same keys checked for deep processing 624 | 625 | // Verify contexts 626 | const bIgnore = ignoreKeysCalls.find(c => c.key === 'b'); 627 | t.truthy(bIgnore); 628 | t.deepEqual(bIgnore.path, ['b']); 629 | t.is(bIgnore.depth, 0); 630 | 631 | const nestedIgnore = ignoreKeysCalls.find(c => c.key === 'nested'); 632 | t.truthy(nestedIgnore); 633 | t.deepEqual(nestedIgnore.path, ['b', 'nested']); 634 | t.is(nestedIgnore.depth, 1); 635 | }); 636 | 637 | test('accessor properties are preserved and sorted', t => { 638 | const calls = []; 639 | const object = {}; 640 | Object.defineProperty(object, 'b', { 641 | get() { 642 | calls.push('b'); 643 | return 1; 644 | }, 645 | enumerable: true, 646 | configurable: true, 647 | }); 648 | Object.defineProperty(object, 'a', { 649 | get() { 650 | calls.push('a'); 651 | return 2; 652 | }, 653 | enumerable: true, 654 | configurable: true, 655 | }); 656 | 657 | const sorted = sortKeys(object); 658 | // Keys are reordered 659 | t.deepEqual(Object.keys(sorted), ['a', 'b']); 660 | // Accessors are preserved 661 | const descA = Object.getOwnPropertyDescriptor(sorted, 'a'); 662 | const descB = Object.getOwnPropertyDescriptor(sorted, 'b'); 663 | t.is(typeof descA.get, 'function'); 664 | t.is(typeof descB.get, 'function'); 665 | // Descriptors match originals (ignoring reorder) 666 | t.deepEqual({enumerable: descA.enumerable, configurable: descA.configurable}, {enumerable: true, configurable: true}); 667 | t.deepEqual({enumerable: descB.enumerable, configurable: descB.configurable}, {enumerable: true, configurable: true}); 668 | 669 | // Reading values still works 670 | t.is(sorted.a, 2); 671 | t.is(sorted.b, 1); 672 | }); 673 | 674 | test('accessor properties do not throw with deep: true', t => { 675 | const object = {}; 676 | Object.defineProperty(object, 'b', { 677 | get() { 678 | return {z: 0, a: 0}; 679 | }, 680 | enumerable: true, 681 | configurable: true, 682 | }); 683 | Object.defineProperty(object, 'a', { 684 | get() { 685 | return {z: 0, a: 0}; 686 | }, 687 | enumerable: true, 688 | configurable: true, 689 | }); 690 | 691 | t.notThrows(() => { 692 | sortKeys(object, {deep: true}); 693 | }); 694 | 695 | const sorted = sortKeys(object, {deep: true}); 696 | // Keys reordered, accessors preserved 697 | t.deepEqual(Object.keys(sorted), ['a', 'b']); 698 | const descA = Object.getOwnPropertyDescriptor(sorted, 'a'); 699 | const descB = Object.getOwnPropertyDescriptor(sorted, 'b'); 700 | t.is(typeof descA.get, 'function'); 701 | t.is(typeof descB.get, 'function'); 702 | }); 703 | 704 | test('sparse arrays: preserve holes and sort nested objects', t => { 705 | const array = []; 706 | array.length = 4; // Create holes 707 | array[1] = {b: 0, a: 0}; 708 | array[3] = {d: 0, c: 0}; 709 | 710 | const sorted = sortKeys(array, {deep: true}); 711 | 712 | // Length preserved 713 | t.is(sorted.length, 4); 714 | // Holes preserved 715 | t.false(0 in sorted); 716 | t.true(1 in sorted); 717 | t.false(2 in sorted); 718 | t.true(3 in sorted); 719 | // Nested objects sorted 720 | t.deepEqual(Object.keys(sorted[1]), ['a', 'b']); 721 | t.deepEqual(Object.keys(sorted[3]), ['c', 'd']); 722 | }); 723 | 724 | test('array extra enumerable properties are not copied', t => { 725 | const array = [{b: 0, a: 0}]; 726 | // Add an extra enumerable prop to array instance 727 | array.foo = 'bar'; 728 | 729 | const sorted = sortKeys(array, {deep: true}); 730 | 731 | // Element deep-processed 732 | t.deepEqual(Object.keys(sorted[0]), ['a', 'b']); 733 | // Extra property not copied to result array 734 | t.false(Object.hasOwn(sorted, 'foo')); 735 | }); 736 | 737 | test('Object.create(null) handling', t => { 738 | const object = Object.create(null); 739 | object.c = 0; 740 | object.a = 0; 741 | object.b = 0; 742 | 743 | const sorted = sortKeys(object); 744 | // Implementation currently relies on is-plain-obj to determine plainness. 745 | // If treated as plain, keys should be sorted; otherwise, unchanged. 746 | const keys = Object.keys(sorted); 747 | const eitherSortedOrOriginal = (keys.join(',') === 'a,b,c') 748 | || (keys.join(',') === 'c,a,b'); 749 | t.true(eitherSortedOrOriginal); 750 | }); 751 | 752 | test('non-plain objects are not deep-processed', t => { 753 | const date = new Date(); 754 | const map = new Map([['z', 0], ['a', 0]]); 755 | const object = { 756 | b: {z: 0, a: 0}, 757 | a: 0, 758 | date, 759 | map, 760 | }; 761 | 762 | const sorted = sortKeys(object, {deep: true}); 763 | 764 | // Keys sorted at root and nested plain objects 765 | t.deepEqual(Object.keys(sorted), ['a', 'b', 'date', 'map']); 766 | t.deepEqual(Object.keys(sorted.b), ['a', 'z']); 767 | 768 | // Non-plain instances are copied by reference 769 | t.is(sorted.date, date); 770 | t.is(sorted.map, map); 771 | }); 772 | 773 | test('non-enumerable keys are not copied and symbol keys are not copied', t => { 774 | const sym = Symbol('s'); 775 | const object = {}; 776 | Object.defineProperty(object, 'hidden', {value: 1, enumerable: false}); 777 | object.a = 0; 778 | object[sym] = 2; 779 | 780 | const sorted = sortKeys(object); 781 | 782 | // Only enumerable string keys present 783 | t.deepEqual(Object.keys(sorted), ['a']); 784 | // Non-enumerable absent 785 | t.false(Object.prototype.propertyIsEnumerable.call(sorted, 'hidden')); 786 | // Symbol not copied 787 | t.false(Object.getOwnPropertySymbols(sorted).includes(sym)); 788 | }); 789 | 790 | test('throws on invalid input', t => { 791 | t.throws(() => sortKeys(123), {instanceOf: TypeError}); 792 | t.throws(() => sortKeys(null), {instanceOf: TypeError}); 793 | t.throws(() => sortKeys(new Map()), {instanceOf: TypeError}); 794 | }); 795 | 796 | test('stable sort: comparator returning 0 preserves original order', t => { 797 | const object = { 798 | b: 0, 799 | a: 0, 800 | c: 0, 801 | d: 0, 802 | }; 803 | const keepOrder = () => 0; 804 | const sorted = sortKeys(object, {compare: keepOrder}); 805 | // Order should be original since sort is stable in Node 20+ 806 | t.deepEqual(Object.keys(sorted), ['b', 'a', 'c', 'd']); 807 | }); 808 | 809 | test('keys containing dots are treated as opaque path elements', t => { 810 | const object = { 811 | 'a.b': {z: 0, a: 0}, 812 | a: 0, 813 | }; 814 | 815 | const sorted = sortKeys(object, { 816 | deep: ({path}) => path[0] === 'a.b', 817 | }); 818 | 819 | deepEqualInOrder(t, sorted, { 820 | a: 0, 821 | 'a.b': {a: 0, z: 0}, 822 | }); 823 | }); 824 | 825 | test('non-plain objects (custom prototype) are invalid input', t => { 826 | const proto = {inherited: 1}; 827 | const object = Object.create(proto); 828 | object.b = 0; 829 | object.a = 0; 830 | 831 | t.throws(() => sortKeys(object), {instanceOf: TypeError}); 832 | }); 833 | 834 | test('aliasing is preserved for duplicate references', t => { 835 | const shared = {z: 0, a: 0}; 836 | const object = {left: shared, right: shared}; 837 | const sorted = sortKeys(object, {deep: true}); 838 | 839 | // Both references should point to the same new object 840 | t.is(sorted.left, sorted.right); 841 | // And that object should be sorted 842 | deepEqualInOrder(t, sorted.left, {a: 0, z: 0}); 843 | }); 844 | 845 | test('deep function can selectively skip odd array indices', t => { 846 | const object = { 847 | items: [ 848 | {z: 0, a: 0}, 849 | {z: 0, a: 0}, 850 | {z: 0, a: 0}, 851 | ], 852 | }; 853 | 854 | const sorted = sortKeys(object, { 855 | deep: ({path}) => !(path[0] === 'items' && Number.isInteger(Number(path[1])) && Number(path[1]) % 2 === 1), 856 | }); 857 | 858 | // Index 0 and 2 deep-processed, index 1 left as-is 859 | t.deepEqual(Object.keys(sorted.items[0]), ['a', 'z']); 860 | t.deepEqual(Object.keys(sorted.items[1]), ['z', 'a']); 861 | t.deepEqual(Object.keys(sorted.items[2]), ['a', 'z']); 862 | }); 863 | 864 | test('accessor getter value is not deep-processed', t => { 865 | const object = {}; 866 | Object.defineProperty(object, 'a', { 867 | get() { 868 | return {z: 0, a: 0}; 869 | }, 870 | enumerable: true, 871 | configurable: true, 872 | }); 873 | Object.defineProperty(object, 'b', { 874 | value: 1, 875 | enumerable: true, 876 | configurable: true, 877 | writable: true, 878 | }); 879 | 880 | const sorted = sortKeys(object, {deep: true}); 881 | // Read the getter and verify its returned object is not sorted 882 | t.deepEqual(Object.keys(sorted.a), ['z', 'a']); 883 | }); 884 | 885 | test('idempotence: sorting twice yields identical result', t => { 886 | const input = {c: {z: 0, a: 0}, b: 0, a: [{d: 0, c: 0}, {b: 0, a: 0}]}; 887 | const once = sortKeys(input, {deep: true}); 888 | const twice = sortKeys(once, {deep: true}); 889 | deepEqualInOrder(t, twice, once); 890 | }); 891 | 892 | test('ignored keys keep original order before sorted keys', t => { 893 | const input = { 894 | c: 0, 895 | x: 1, 896 | a: 0, 897 | y: 2, 898 | b: 0, 899 | }; 900 | const sorted = sortKeys(input, { 901 | ignoreKeys: ['x', 'y'], 902 | compare: (l, r) => l.localeCompare(r), 903 | }); 904 | deepEqualInOrder(t, sorted, { 905 | x: 1, 906 | y: 2, 907 | a: 0, 908 | b: 0, 909 | c: 0, 910 | }); 911 | }); 912 | 913 | test('depth semantics for arrays and objects in deep function', t => { 914 | const calls = []; 915 | const input = { 916 | root: { 917 | obj: {x: 0}, 918 | arr: [{y: 0}], 919 | }, 920 | }; 921 | sortKeys(input, { 922 | deep(context) { 923 | calls.push({ 924 | path: context.path, 925 | depth: context.depth, 926 | isArray: Array.isArray(context.value), 927 | }); 928 | return true; 929 | }, 930 | }); 931 | 932 | const find = p => calls.find(c => JSON.stringify(c.path) === JSON.stringify(p)); 933 | // Root 934 | t.truthy(find(['root'])); 935 | t.is(find(['root']).depth, 0); 936 | // Root.obj 937 | t.truthy(find(['root', 'obj'])); 938 | t.is(find(['root', 'obj']).depth, 1); 939 | // Root.arr (array at depth 1) 940 | t.truthy(find(['root', 'arr'])); 941 | t.is(find(['root', 'arr']).depth, 1); 942 | // Root.arr.0 (array item increases depth) 943 | t.truthy(find(['root', 'arr', '0'])); 944 | t.is(find(['root', 'arr', '0']).depth, 2); 945 | }); 946 | --------------------------------------------------------------------------------