├── .github └── workflows │ └── ci.yml ├── .gitignore ├── deno.json ├── lib ├── mod.bench.ts ├── mod.test.ts └── mod.ts ├── license ├── package.json ├── readme.md └── scripts └── build.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["**"] 6 | tags: ["v**"] 7 | 8 | env: 9 | DENO_VERSION: 1.46 10 | 11 | jobs: 12 | health: 13 | name: Health 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: denoland/setup-deno@v1 18 | with: 19 | deno-version: ${{ env.DENO_VERSION }} 20 | 21 | - run: deno lint 22 | - run: deno fmt --check 23 | - run: deno check lib/*.ts 24 | 25 | - run: deno test --no-check --coverage 26 | - run: deno coverage 27 | 28 | dryrun: 29 | needs: [health] 30 | name: "Publish (dry run)" 31 | if: ${{ !startsWith(github.ref, 'refs/tags/v') }} 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: denoland/setup-deno@v1 36 | with: 37 | deno-version: ${{ env.DENO_VERSION }} 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | registry-url: "https://registry.npmjs.org" 42 | 43 | - run: deno task build 44 | - run: npm publish --dry-run 45 | working-directory: npm 46 | - run: deno publish --no-check --dry-run 47 | 48 | # https://jsr.io/docs/publishing-packages#publishing-from-github-actions 49 | # https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages#publishing-packages-to-the-npm-registry 50 | publish: 51 | needs: [health] 52 | name: Publish 53 | runs-on: ubuntu-latest 54 | if: startsWith(github.ref, 'refs/tags/v') 55 | permissions: 56 | contents: read 57 | id-token: write # -> authentication 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: denoland/setup-deno@v1 61 | with: 62 | deno-version: ${{ env.DENO_VERSION }} 63 | - uses: actions/setup-node@v4 64 | with: 65 | node-version: 20 66 | registry-url: "https://registry.npmjs.org" 67 | 68 | - run: deno task build 69 | 70 | - name: "Publish → jsr" 71 | if: ${{ !contains(github.ref, '-next.') }} 72 | run: deno publish --no-check 73 | 74 | - name: "Publish → npm" 75 | if: ${{ !contains(github.ref, '-next.') }} 76 | run: npm publish --provenance --access public 77 | working-directory: npm 78 | env: 79 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 80 | 81 | - name: "Publish → npm (pre-release)" 82 | if: ${{ contains(github.ref, '-next.') }} 83 | run: npm publish --tag next --provenance --access public 84 | working-directory: npm 85 | env: 86 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /coverage 4 | /npm 5 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.2", 3 | "name": "@mr/object-identity", 4 | "exports": "./lib/mod.ts", 5 | "tasks": { 6 | "build": "deno run -A scripts/build.ts" 7 | }, 8 | "imports": { 9 | "@std/assert": "jsr:@std/assert@^1", 10 | "@std/path": "jsr:@std/path@^1" 11 | }, 12 | "lock": false, 13 | "lint": { 14 | "rules": { 15 | "exclude": [ 16 | "no-var", 17 | "prefer-const", 18 | "no-cond-assign", 19 | "no-inner-declarations", 20 | "no-explicit-any", 21 | "no-fallthrough" 22 | ] 23 | } 24 | }, 25 | "fmt": { 26 | "lineWidth": 100, 27 | "singleQuote": true, 28 | "useTabs": true 29 | }, 30 | "exclude": [ 31 | "npm", 32 | "coverage" 33 | ], 34 | "publish": { 35 | "include": [ 36 | "lib/mod.ts", 37 | "license", 38 | "readme.md" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/mod.bench.ts: -------------------------------------------------------------------------------- 1 | import objectHash from 'npm:object-hash'; 2 | import jsonStableStringify from 'npm:json-stable-stringify'; 3 | import { stringify as tinyStableStringify } from 'npm:tiny-stable-stringify'; 4 | 5 | import { identify } from './mod.ts'; 6 | 7 | const getObject = () => { 8 | const c = [1]; 9 | // @ts-expect-error circular 10 | c.push(c); 11 | return { 12 | a: { b: ['c', new Set(['d', new Map([['e', 'f']]), c, 'g'])] }, 13 | d: new Date(), 14 | r: /a/, 15 | }; 16 | }; 17 | 18 | Deno.bench({ 19 | name: 'object-identity', 20 | baseline: true, 21 | fn() { 22 | let _ = identify(getObject()); 23 | }, 24 | }); 25 | 26 | Deno.bench({ 27 | name: 'object-hash', 28 | fn() { 29 | const options = { algorithm: 'passthrough', unorderedSets: false }; 30 | let _ = objectHash(getObject(), options); 31 | }, 32 | }); 33 | 34 | Deno.bench({ 35 | name: 'json-stable-stringify', 36 | fn() { 37 | let _ = jsonStableStringify(getObject()); 38 | }, 39 | }); 40 | 41 | Deno.bench({ 42 | name: 'tiny-stable-stringify', 43 | fn() { 44 | let _ = tinyStableStringify(getObject()); 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /lib/mod.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | assertEquals, 4 | assertInstanceOf, 5 | assertNotEquals, 6 | assertNotMatch, 7 | } from '@std/assert'; 8 | import { identify } from '../lib/mod.ts'; 9 | 10 | Deno.test('exports', () => { 11 | assertInstanceOf(identify, Function); 12 | }); 13 | 14 | Deno.test('arrays :: flat', () => { 15 | assertEquals(identify([1, 2, 3]), identify([1, 2, 3])); 16 | }); 17 | 18 | Deno.test('arrays :: order should not matter', () => { 19 | assertNotEquals(identify([3, 2, 1]), identify([1, 2, 3])); 20 | }); 21 | 22 | Deno.test('arrays :: nested', () => { 23 | assertEquals( 24 | identify([ 25 | [3, 2, 1], 26 | [1, 2, 3], 27 | ]), 28 | identify([ 29 | [3, 2, 1], 30 | [1, 2, 3], 31 | ]), 32 | ); 33 | }); 34 | 35 | Deno.test('arrays :: circular', () => { 36 | const arr = [1, 2, 3]; 37 | // @ts-expect-error circular 38 | arr.push(arr); 39 | assert(identify(arr)); 40 | assertEquals(identify(arr), 'a123~1'); 41 | assertEquals(identify(arr), identify(arr)); 42 | }); 43 | 44 | Deno.test('objects :: basic', () => { 45 | assertEquals(identify({ foo: 'bar' }), identify({ foo: 'bar' })); 46 | }); 47 | 48 | Deno.test('objects :: key ordering', () => { 49 | assertEquals( 50 | identify({ one: 'one', two: 'two' }), 51 | identify({ two: 'two', one: 'one' }), 52 | ); 53 | }); 54 | 55 | Deno.test('objects :: complex keys', () => { 56 | const d = Date.now(); 57 | assertEquals( 58 | identify({ [123]: 'one', [d]: 'two' }), 59 | identify({ 60 | [123]: 'one', 61 | [d]: 'two', 62 | }), 63 | ); 64 | }); 65 | 66 | Deno.test('objects :: nested', () => { 67 | assertEquals( 68 | identify({ a: { b: 'c' }, d: { e: { f: 'g' } } }), 69 | identify({ 70 | d: { e: { f: 'g' } }, 71 | a: { b: 'c' }, 72 | }), 73 | ); 74 | }); 75 | 76 | Deno.test('objects :: circular', () => { 77 | const o = { a: 'b' }; 78 | // @ts-expect-error circular 79 | o['c'] = o; 80 | assert(identify(o)); 81 | assertEquals(identify(o), identify(o)); 82 | }); 83 | 84 | Deno.test('objects :: partial circular', () => { 85 | const o = { v: 1 }; 86 | const a = ['a', o]; 87 | assertEquals(identify(a), identify(a)); 88 | }); 89 | 90 | // Right now they do match, because the o1 lookup is the same as o2 91 | // as the reference is still the same, so the weakmap is the same 92 | /*Objects.skip('with samey circular shoudlnt match', () => { 93 | const o1: any = { a: 1, b: 2 }; 94 | const o2: any = { a: 1, b: 2 }; 95 | o1['c'] = o1; 96 | o1['d'] = o2; 97 | 98 | o2['c'] = o2; // 👈 #1 99 | 100 | const a = identify(o1); 101 | 102 | o2['c'] = o1; // 👈 different from #1 103 | 104 | const b = identify(o1); 105 | 106 | assertNotEquals(a, b, `${a} != ${b}`); 107 | });*/ 108 | 109 | Deno.test('objects :: same values between types shouldnt match', () => { 110 | assertNotEquals(identify({ a: 'b' }), identify(['a', 'b'])); 111 | }); 112 | 113 | Deno.test('objects :: same hash for Map or Object', () => { 114 | assertEquals(identify({ a: 'b' }), identify(new Map([['a', 'b']]))); 115 | }); 116 | 117 | Deno.test('sets :: shouldnt be ordered', () => { 118 | assertNotEquals( 119 | identify(new Set([1, 2, 3])), 120 | identify(new Set([3, 2, 1])), 121 | ); 122 | }); 123 | 124 | Deno.test('sets :: shouldnt be ordered', () => { 125 | assertNotEquals( 126 | identify(new Set([{ a: 'a' }, { b: 'b' }])), 127 | identify(new Set([{ b: 'b' }, { a: 'a' }])), 128 | ); 129 | }); 130 | 131 | Deno.test('sets :: circular', () => { 132 | const s = new Set([1, 2, 3]); 133 | // @ts-expect-error circular 134 | s.add(s); 135 | assert(identify(s)); 136 | assertEquals(identify(s), identify(s)); 137 | }); 138 | 139 | Deno.test('maps :: basic', () => { 140 | assertEquals( 141 | identify( 142 | new Map([ 143 | ['a', 'b'], 144 | ['c', 'd'], 145 | ]), 146 | ), 147 | identify( 148 | new Map([ 149 | ['c', 'd'], 150 | ['a', 'b'], 151 | ]), 152 | ), 153 | ); 154 | }); 155 | 156 | Deno.test('maps :: circular', () => { 157 | const m = new Map([ 158 | ['a', 'b'], 159 | ['c', 'd'], 160 | ]); 161 | // @ts-expect-error circular 162 | m.set('e', m); 163 | assert(identify(m)); 164 | assertEquals(identify(m), identify(m)); 165 | }); 166 | 167 | Deno.test('values :: primitives', () => { 168 | const t = (v: any) => 169 | assertEquals( 170 | identify(v), 171 | identify(v), 172 | `Value ${v} should have hashed correctly.`, 173 | ); 174 | 175 | t('test'); 176 | t(new Date()); 177 | t(NaN); 178 | t(true); 179 | t(false); 180 | t(/test/); 181 | t(123); 182 | t(null); 183 | t(undefined); 184 | // TODO: Solve for symbols 185 | // t(Symbol()); 186 | // t(Symbol("test")); 187 | }); 188 | 189 | Deno.test('values :: circular ref should be consistent', () => { 190 | let o: any = { a: 1, c: 2 }; 191 | o.b = o; 192 | o.d = new Map(); // the map is seen 2nd 193 | o.d.set('x', o.d); 194 | 195 | assertEquals(Object.keys(o), ['a', 'c', 'b', 'd']); 196 | 197 | const a = identify(o); 198 | 199 | o = { a: 1 }; 200 | o.d = new Map(); // the map is seen first 201 | o.d.set('x', o.d); 202 | o.b = o; 203 | o.c = 2; 204 | 205 | assertEquals(Object.keys(o), ['a', 'd', 'b', 'c']); 206 | 207 | const b = identify(o); 208 | 209 | // the circular ref should be the same 210 | assertEquals(a, b, `${a} === ${b}`); 211 | }); 212 | 213 | Deno.test('values :: circular deeply nested objects should equal', () => { 214 | const o1: any = { 215 | b: { 216 | c: 123, 217 | }, 218 | }; 219 | 220 | o1.b.d = o1; 221 | o1.x = [9, o1.b]; 222 | 223 | const o2: any = { 224 | b: { 225 | c: 123, 226 | }, 227 | }; 228 | 229 | o2.b.d = o2; 230 | o2.x = [9, o2.b]; 231 | const a = identify(o1); 232 | const b = identify(o2); 233 | assertEquals(a, b, `${a} === ${b}`); 234 | }); 235 | 236 | Deno.test('values :: all elements visited', () => { 237 | const c = [1]; 238 | // @ts-expect-error circular 239 | c.push(c); 240 | const hash = identify({ 241 | a: { b: ['c', new Set(['d', new Map([['e', 'f']]), c, 'g'])] }, 242 | }); 243 | assertEquals(hash, 'oaobacadoefa1~5g'); 244 | }); 245 | 246 | Deno.test('values :: should only be seen once', () => { 247 | const hash = identify({ 248 | a: [[1], [2], [3]], 249 | b: new Set([new Set([1]), new Set([2]), new Set([3])]), 250 | }); 251 | assertNotMatch(hash, /~\d+/); 252 | }); 253 | -------------------------------------------------------------------------------- /lib/mod.ts: -------------------------------------------------------------------------------- 1 | let seen = new WeakMap(); 2 | 3 | function walk(input: any, ref_index: number) { 4 | if (input == null || typeof input !== 'object') return String(input); 5 | 6 | let tmp: any; 7 | let out = ''; 8 | let i = 0; 9 | let type = Object.prototype.toString.call(input); 10 | if ( 11 | !(type === '[object RegExp]' || type === '[object Date]') && 12 | seen.has(input) 13 | ) { 14 | return seen.get(input)!; 15 | } 16 | seen.set(input, '~' + ++ref_index); 17 | 18 | switch (type) { 19 | case '[object Set]': 20 | tmp = Array.from(input as Set); 21 | case '[object Array]': 22 | { 23 | tmp ||= input; 24 | out += 'a'; 25 | for (; i < tmp.length; out += walk(tmp[i++], ref_index)); 26 | } 27 | break; 28 | 29 | case '[object Object]': 30 | { 31 | out += 'o'; 32 | tmp = Object.keys(input).sort(); 33 | for ( 34 | ; 35 | i < tmp.length; 36 | out += tmp[i] + walk(input[tmp[i++]], ref_index) 37 | ); 38 | } 39 | break; 40 | 41 | case '[object Map]': 42 | { 43 | out += 'o'; 44 | tmp = Array.from((input as Map).keys()).sort(); 45 | for ( 46 | ; 47 | i < tmp.length; 48 | out += tmp[i] + walk(input.get(tmp[i++]), ref_index) 49 | ); 50 | } 51 | break; 52 | 53 | case '[object Date]': 54 | return 'd' + +input; 55 | 56 | case '[object RegExp]': 57 | return 'r' + input.source + input.flags; 58 | 59 | default: 60 | throw new Error(`Unsupported value ${input}`); 61 | } 62 | 63 | seen.set(input, out); 64 | return out; 65 | } 66 | 67 | /** 68 | * Creates a shape equivalent identifier for an input object. 69 | * This is useful for comparing objects, where keys could be provided in any order. 70 | * 71 | * @example 72 | * ```ts 73 | * const obj = { a: 1, b: 2 }; 74 | * const obj2 = { b: 2, a: 1 }; 75 | * 76 | * console.log(identify(obj) === identify(obj2)); // true 77 | * ``` 78 | */ 79 | export function identify(input: T): string { 80 | return walk(input, 0); 81 | } 82 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Marais Rossouw 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "object-identity", 3 | "version": "0.1.2", 4 | "repository": "maraisr/object-identity", 5 | "license": "MIT", 6 | "author": "Marais Rossow (https://marais.io)", 7 | "keywords": [ 8 | "object", 9 | "identity", 10 | "hash", 11 | "fingerprint" 12 | ], 13 | "sideEffects": false, 14 | "exports": { 15 | ".": { 16 | "types": "./index.d.mts", 17 | "import": "./index.mjs" 18 | }, 19 | "./package.json": "./package.json" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | # object-identity [![licenses](https://licenses.dev/b/npm/object-identity?style=dark)](https://licenses.dev/npm/object-identity) 6 | 7 | 8 | 9 | **A utility that provides a stable identity of an object** 10 | 11 |
12 |
13 | 14 | 15 | 16 | This is free to use software, but if you do like it, consider supporting me ❤️ 17 | 18 | [![sponsor me](https://badgen.net/badge/icon/sponsor?icon=github&label&color=gray)](https://github.com/sponsors/maraisr) 19 | [![buy me a coffee](https://badgen.net/badge/icon/buymeacoffee?icon=buymeacoffee&label&color=gray)](https://www.buymeacoffee.com/marais) 20 | 21 | 22 | 23 |
24 | 25 | ## ⚡ Features 26 | 27 | - ✅ **Intuitive** 28 | - 🌪 **Recursive/Circular support** 29 | - 🏎 **Performant** — check the [benchmarks](#-benchmark). 30 | - 🪶 **Lightweight** — a mere 387B and no 31 | [dependencies](https://npm.anvaka.com/#/view/2d/object-identity/). 32 | 33 | ## ⚙️ Install 34 | 35 | - **npm** — available as [`object-identity`](https://www.npmjs.com/package/object-identity) 36 | - **JSR** — available as [`@mr/object-identity`](https://jsr.io/@mr/object-identity) 37 | 38 | ## 🚀 Usage 39 | 40 | ```ts 41 | import { identify } from 'object-identity'; 42 | 43 | // ~> identity the object 44 | const id1 = identify({ a: new Set(['b', 'c', new Map([['d', 'e']])]) }); 45 | // ~> an entirely different object, but structurally the same 46 | const id2 = identify({ a: new Set(['b', 'c', new Map([['e', 'e']])]) }); 47 | 48 | // they should equal 49 | assert.toEqual(hashA, hashB); 50 | ``` 51 | 52 | ## 💨 Benchmark 53 | 54 | ``` 55 | benchmark time (avg) iter/s (min … max) p75 p99 p995 56 | --------------------------------------------------------------------------- ----------------------------- 57 | object-identity 2.2 µs/iter 453,803.6 (1.99 µs … 2.44 µs) 2.35 µs 2.44 µs 2.44 µs 58 | object-hash 8.76 µs/iter 114,168.3 (7.96 µs … 225.33 µs) 8.71 µs 11.75 µs 14.92 µs 59 | json-stable-stringify 1.77 µs/iter 565,184.5 (1.75 µs … 1.86 µs) 1.77 µs 1.86 µs 1.86 µs 60 | tiny-stable-stringify 1.63 µs/iter 612,009.4 (1.62 µs … 1.68 µs) 1.64 µs 1.68 µs 1.68 µs 61 | 62 | summary 63 | object-identity 64 | 1.35x slower than tiny-stable-stringify 65 | 1.25x slower than json-stable-stringify 66 | 3.97x faster than object-hash 67 | ``` 68 | 69 | > ^ `object-identity` is not as feature-full it's alternatives, specifically around `function` 70 | > values and other node builtins. So take this benchmark with a grain of salt, as it's only testing 71 | > "json-like" payloads. 72 | 73 | ## License 74 | 75 | MIT © [Marais Rossouw](https://marais.io) 76 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | // Credit @lukeed https://github.com/lukeed/empathic/blob/main/scripts/build.ts 2 | 3 | // Publish: 4 | // -> edit package.json version 5 | // -> edit deno.json version 6 | // $ git commit "release: x.x.x" 7 | // $ git tag "vx.x.x" 8 | // $ git push origin main --tags 9 | // #-> CI builds w/ publish 10 | 11 | import oxc from 'npm:oxc-transform@^0.30'; 12 | import { join, resolve } from '@std/path'; 13 | 14 | import denoJson from '../deno.json' with { type: 'json' }; 15 | 16 | const outdir = resolve('npm'); 17 | 18 | let Inputs; 19 | if (typeof denoJson.exports === 'string') Inputs = { '.': denoJson.exports }; 20 | else Inputs = denoJson.exports; 21 | 22 | async function transform(name: string, filename: string) { 23 | if (name === '.') name = 'index'; 24 | name = name.replace(/^\.\//, ''); 25 | 26 | let entry = resolve(filename); 27 | let source = await Deno.readTextFile(entry); 28 | 29 | let xform = oxc.transform(entry, source, { 30 | typescript: { 31 | onlyRemoveTypeImports: true, 32 | declaration: { 33 | stripInternal: true, 34 | }, 35 | }, 36 | }); 37 | 38 | if (xform.errors.length > 0) bail('transform', xform.errors); 39 | 40 | let outfile = `${outdir}/${name}.d.mts`; 41 | console.log('> writing "%s" file', outfile); 42 | await Deno.writeTextFile(outfile, xform.declaration!); 43 | 44 | outfile = `${outdir}/${name}.mjs`; 45 | console.log('> writing "%s" file', outfile); 46 | await Deno.writeTextFile(outfile, xform.code); 47 | } 48 | 49 | if (exists(outdir)) { 50 | console.log('! removing "npm" directory'); 51 | await Deno.remove(outdir, { recursive: true }); 52 | } 53 | await Deno.mkdir(outdir); 54 | 55 | for (let [name, filename] of Object.entries(Inputs)) await transform(name, filename); 56 | 57 | await copy('package.json'); 58 | await copy('readme.md'); 59 | await copy('license'); 60 | 61 | // --- 62 | 63 | function bail(label: string, errors: string[]): never { 64 | console.error('[%s] error(s)\n', label, errors.join('')); 65 | Deno.exit(1); 66 | } 67 | 68 | function exists(path: string) { 69 | try { 70 | Deno.statSync(path); 71 | return true; 72 | } catch (_) { 73 | return false; 74 | } 75 | } 76 | 77 | function copy(file: string) { 78 | if (exists(file)) { 79 | let outfile = join(outdir, file); 80 | console.log('> writing "%s" file', outfile); 81 | return Deno.copyFile(file, outfile); 82 | } 83 | } 84 | --------------------------------------------------------------------------------