Skip to content

Commit 94a271b

Browse files
committed
feat(utils): add record guards and pure helpers to @sim/utils
Add isRecordLike (loose, non-prototype-checked record guard) and sortObjectKeysDeep; relocate isPlainRecord (strict) and normalizeEmail into @sim/utils so they are reusable across apps and packages. Unit tests cover the loose-vs-strict distinction, deep key sorting, and email normalization.
1 parent cefb2dc commit 94a271b

5 files changed

Lines changed: 146 additions & 3 deletions

File tree

packages/utils/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ export {
1212
} from './formatting.js'
1313
export { noop, sleep } from './helpers.js'
1414
export { generateId, generateShortId, isValidUuid } from './id.js'
15-
export { filterUndefined, omit } from './object.js'
15+
export {
16+
filterUndefined,
17+
isPlainRecord,
18+
isRecordLike,
19+
omit,
20+
sortObjectKeysDeep,
21+
} from './object.js'
1622
export {
1723
generateRandomBytes,
1824
generateRandomHex,
@@ -24,4 +30,4 @@ export {
2430
} from './random.js'
2531
export type { BackoffOptions } from './retry.js'
2632
export { backoffWithJitter, parseRetryAfter } from './retry.js'
27-
export { truncate } from './string.js'
33+
export { normalizeEmail, truncate } from './string.js'

packages/utils/src/object.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isPlainRecord, isRecordLike, sortObjectKeysDeep } from './object.js'
6+
7+
class Sample {
8+
value = 1
9+
}
10+
11+
describe('isRecordLike', () => {
12+
it('returns true for plain objects, Date, and class instances', () => {
13+
expect(isRecordLike({})).toBe(true)
14+
expect(isRecordLike(new Date())).toBe(true)
15+
expect(isRecordLike(new Sample())).toBe(true)
16+
})
17+
18+
it('returns false for arrays, null, and primitives', () => {
19+
expect(isRecordLike([])).toBe(false)
20+
expect(isRecordLike(null)).toBe(false)
21+
expect(isRecordLike('not-a-record')).toBe(false)
22+
expect(isRecordLike(42)).toBe(false)
23+
})
24+
})
25+
26+
describe('isPlainRecord', () => {
27+
it('returns true for plain objects', () => {
28+
expect(isPlainRecord({})).toBe(true)
29+
expect(isPlainRecord(Object.create(null))).toBe(true)
30+
})
31+
32+
it('returns false for Date, class instances, arrays, and null', () => {
33+
expect(isPlainRecord(new Date())).toBe(false)
34+
expect(isPlainRecord(new Sample())).toBe(false)
35+
expect(isPlainRecord([])).toBe(false)
36+
expect(isPlainRecord(null)).toBe(false)
37+
})
38+
})
39+
40+
describe('sortObjectKeysDeep', () => {
41+
it('sorts keys deeply and recurses into array elements', () => {
42+
const input = {
43+
b: 1,
44+
a: { d: 4, c: 3 },
45+
list: [{ z: 26, y: 25 }],
46+
}
47+
const sorted = sortObjectKeysDeep(input)
48+
expect(JSON.stringify(sorted)).toBe(
49+
JSON.stringify({ a: { c: 3, d: 4 }, b: 1, list: [{ y: 25, z: 26 }] })
50+
)
51+
})
52+
53+
it('returns primitives and null unchanged', () => {
54+
expect(sortObjectKeysDeep(null)).toBe(null)
55+
expect(sortObjectKeysDeep(42)).toBe(42)
56+
expect(sortObjectKeysDeep('x')).toBe('x')
57+
})
58+
59+
it('preserves array order while sorting element keys', () => {
60+
const sorted = sortObjectKeysDeep([
61+
{ b: 1, a: 2 },
62+
{ d: 3, c: 4 },
63+
])
64+
expect(JSON.stringify(sorted)).toBe(
65+
JSON.stringify([
66+
{ a: 2, b: 1 },
67+
{ c: 4, d: 3 },
68+
])
69+
)
70+
})
71+
})

packages/utils/src/object.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,51 @@ export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Om
2222
export function filterUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
2323
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>
2424
}
25+
26+
/**
27+
* Returns true only for object-map values, excluding arrays and null.
28+
*/
29+
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
30+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
31+
return false
32+
}
33+
34+
const prototype = Object.getPrototypeOf(value)
35+
return prototype === Object.prototype || prototype === null
36+
}
37+
38+
/**
39+
* Returns true for any non-null, non-array object — the LOOSE record check.
40+
* Unlike {@link isPlainRecord}, this does NOT inspect the prototype, so it also
41+
* accepts class instances, Date, Map, etc. Use this when you only need to know a
42+
* value is an indexable object (e.g. before spreading or Object.entries), and
43+
* reach for isPlainRecord when you must exclude exotic objects.
44+
*/
45+
export function isRecordLike(value: unknown): value is Record<string, unknown> {
46+
return typeof value === 'object' && value !== null && !Array.isArray(value)
47+
}
48+
49+
/**
50+
* Recursively sorts the keys of every plain object reachable from {@link value},
51+
* preserving array order while recursing into array elements. Primitives and
52+
* `null` are returned unchanged. Produces a structurally equivalent value with
53+
* deterministic key ordering, suitable for stable comparison or serialization.
54+
*
55+
* @remarks Only string keys are sorted and retained; symbol keys are dropped,
56+
* matching the stable-serialization callers this serves.
57+
*/
58+
export function sortObjectKeysDeep(value: unknown): unknown {
59+
if (Array.isArray(value)) {
60+
return value.map(sortObjectKeysDeep)
61+
}
62+
if (value !== null && typeof value === 'object') {
63+
const obj = value as Record<string, unknown>
64+
return Object.keys(obj)
65+
.sort()
66+
.reduce((result: Record<string, unknown>, key: string) => {
67+
result[key] = sortObjectKeysDeep(obj[key])
68+
return result
69+
}, {})
70+
}
71+
return value
72+
}

packages/utils/src/string.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { isVersionedType, stripVersionSuffix, truncate } from './string.js'
5+
import { isVersionedType, normalizeEmail, stripVersionSuffix, truncate } from './string.js'
66

77
describe('truncate', () => {
88
it('appends the suffix when the string exceeds the slice length', () => {
@@ -55,3 +55,13 @@ describe('isVersionedType', () => {
5555
expect(isVersionedType('x')).toBe(false)
5656
})
5757
})
58+
59+
describe('normalizeEmail', () => {
60+
it('trims surrounding whitespace and lowercases', () => {
61+
expect(normalizeEmail(' USER@Example.COM ')).toBe('user@example.com')
62+
})
63+
64+
it('leaves an already-normalized email unchanged', () => {
65+
expect(normalizeEmail('user@example.com')).toBe('user@example.com')
66+
})
67+
})

packages/utils/src/string.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,11 @@ export function stripVersionSuffix(value: string): string {
3737
export function isVersionedType(value: string): boolean {
3838
return /_v\d+$/.test(value)
3939
}
40+
41+
/**
42+
* Normalizes an email address for comparison and storage by trimming
43+
* surrounding whitespace and lowercasing.
44+
*/
45+
export function normalizeEmail(email: string): string {
46+
return email.trim().toLowerCase()
47+
}

0 commit comments

Comments
 (0)