Skip to content

Commit c1b5027

Browse files
committed
Add support for Symbol keys with includeSymbols option
Fixes #50
1 parent 0831322 commit c1b5027

5 files changed

Lines changed: 383 additions & 69 deletions

File tree

index.d.ts

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Return this value from a `mapper` function to remove a key from an object.
55
```
66
import mapObject, {mapObjectSkip} from 'map-obj';
77
8-
const object = {one: 1, two: 2}
9-
const mapper = (key, value) => value === 1 ? [key, value] : mapObjectSkip
8+
const object = {one: 1, two: 2};
9+
const mapper = (key, value) => value === 1 ? [key, value] : mapObjectSkip;
1010
const result = mapObject(object, mapper);
1111
1212
console.log(result);
@@ -15,9 +15,29 @@ console.log(result);
1515
*/
1616
export const mapObjectSkip: unique symbol;
1717

18+
/**
19+
Mapper function for transforming object keys and values.
20+
*/
1821
export type Mapper<
19-
SourceObjectType extends Record<string, unknown>,
20-
MappedObjectKeyType extends string,
22+
SourceObjectType extends Record<string | symbol, unknown>,
23+
MappedObjectKeyType extends string | symbol,
24+
MappedObjectValueType,
25+
> = (
26+
sourceKey: Extract<keyof SourceObjectType, string>,
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<string | symbol, unknown>,
40+
MappedObjectKeyType extends string | symbol,
2141
MappedObjectValueType,
2242
> = (
2343
sourceKey: keyof SourceObjectType,
@@ -35,8 +55,8 @@ Mapper used when `{deep: true}` is enabled.
3555
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.
3656
*/
3757
type DeepMapper<
38-
SourceObjectType extends Record<string, unknown>,
39-
MappedObjectKeyType extends string,
58+
SourceObjectType extends Record<string | symbol, unknown>,
59+
MappedObjectKeyType extends string | symbol,
4060
MappedObjectValueType,
4161
> = (
4262
sourceKey: string,
@@ -48,35 +68,65 @@ type DeepMapper<
4868
mapperOptions?: MapperOptions,
4969
] | typeof mapObjectSkip;
5070

71+
/**
72+
Deep mapper when `includeSymbols: true` is enabled.
73+
*/
74+
type DeepMapperWithSymbols<
75+
SourceObjectType extends Record<string | symbol, unknown>,
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+
5188
export type Options = {
5289
/**
5390
Recurse nested objects and objects in arrays.
5491
5592
@default false
5693
57-
Built-in objects like `RegExp`, `Error`, `Date`, and `Blob` are not recursed into. Special objects like Jest matchers are also automatically excluded.
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.
5895
*/
5996
readonly deep?: boolean;
6097

6198
/**
62-
The target object to map properties on to.
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.
63109
64110
@default {}
65111
*/
66-
readonly target?: Record<string, unknown>;
112+
readonly target?: Record<string | symbol, unknown>;
67113
};
68114

69115
export type DeepOptions = {
70116
readonly deep: true;
71117
} & Options;
72118

73-
export type TargetOptions<TargetObjectType extends Record<string, unknown>> = {
119+
export type TargetOptions<TargetObjectType extends Record<string | symbol, unknown>> = {
74120
readonly target: TargetObjectType;
75121
} & Options;
76122

123+
export type SymbolOptions = {
124+
readonly includeSymbols: true;
125+
} & Options;
126+
77127
export type MapperOptions = {
78128
/**
79-
Whether `targetValue` should be recursed.
129+
Whether to recurse into `targetValue`.
80130
81131
Requires `deep: true`.
82132
@@ -110,31 +160,84 @@ const newObject = mapObject({FOO: true, bAr: {bAz: true}}, (key, value) => [key.
110160
// Filter out specific values
111161
const newObject = mapObject({one: 1, two: 2}, (key, value) => value === 1 ? [key, value] : mapObjectSkip);
112162
//=> {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'}
113168
```
114169
*/
170+
// Overloads with includeSymbols: true
171+
export default function mapObject<
172+
SourceObjectType extends Record<string | symbol, unknown>,
173+
TargetObjectType extends Record<string | symbol, unknown>,
174+
MappedObjectKeyType extends string | symbol,
175+
MappedObjectValueType,
176+
>(
177+
source: SourceObjectType,
178+
mapper: DeepMapperWithSymbols<SourceObjectType, MappedObjectKeyType, MappedObjectValueType>,
179+
options: DeepOptions & SymbolOptions & TargetOptions<TargetObjectType>
180+
): TargetObjectType & Record<string | symbol, unknown>;
181+
export default function mapObject<
182+
SourceObjectType extends Record<string | symbol, unknown>,
183+
MappedObjectKeyType extends string | symbol,
184+
MappedObjectValueType,
185+
>(
186+
source: SourceObjectType,
187+
mapper: DeepMapperWithSymbols<SourceObjectType, MappedObjectKeyType, MappedObjectValueType>,
188+
options: DeepOptions & SymbolOptions
189+
): Record<string | symbol, unknown>;
190+
export default function mapObject<
191+
SourceObjectType extends Record<string | symbol, unknown>,
192+
TargetObjectType extends Record<string | symbol, unknown>,
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<TargetObjectType>
203+
): TargetObjectType & Record<MappedObjectKeyType, MappedObjectValueType>;
204+
export default function mapObject<
205+
SourceObjectType extends Record<string | symbol, unknown>,
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<MappedObjectKeyType, MappedObjectValueType>;
217+
// Overloads without includeSymbols (default)
115218
export default function mapObject<
116-
SourceObjectType extends Record<string, unknown>,
117-
TargetObjectType extends Record<string, unknown>,
118-
MappedObjectKeyType extends string,
219+
SourceObjectType extends Record<string | symbol, unknown>,
220+
TargetObjectType extends Record<string | symbol, unknown>,
221+
MappedObjectKeyType extends string | symbol,
119222
MappedObjectValueType,
120223
>(
121224
source: SourceObjectType,
122225
mapper: DeepMapper<SourceObjectType, MappedObjectKeyType, MappedObjectValueType>,
123226
options: DeepOptions & TargetOptions<TargetObjectType>
124-
): TargetObjectType & Record<string, unknown>;
227+
): TargetObjectType & Record<string | symbol, unknown>;
125228
export default function mapObject<
126-
SourceObjectType extends Record<string, unknown>,
127-
MappedObjectKeyType extends string,
229+
SourceObjectType extends Record<string | symbol, unknown>,
230+
MappedObjectKeyType extends string | symbol,
128231
MappedObjectValueType,
129232
>(
130233
source: SourceObjectType,
131234
mapper: DeepMapper<SourceObjectType, MappedObjectKeyType, MappedObjectValueType>,
132235
options: DeepOptions
133-
): Record<string, unknown>;
236+
): Record<string | symbol, unknown>;
134237
export default function mapObject<
135-
SourceObjectType extends Record<string, unknown>,
136-
TargetObjectType extends Record<string, unknown>,
137-
MappedObjectKeyType extends string,
238+
SourceObjectType extends Record<string | symbol, unknown>,
239+
TargetObjectType extends Record<string | symbol, unknown>,
240+
MappedObjectKeyType extends string | symbol,
138241
MappedObjectValueType,
139242
>(
140243
source: SourceObjectType,
@@ -146,8 +249,8 @@ export default function mapObject<
146249
options: TargetOptions<TargetObjectType>
147250
): TargetObjectType & Record<MappedObjectKeyType, MappedObjectValueType>;
148251
export default function mapObject<
149-
SourceObjectType extends Record<string, unknown>,
150-
MappedObjectKeyType extends string,
252+
SourceObjectType extends Record<string | symbol, unknown>,
253+
MappedObjectKeyType extends string | symbol,
151254
MappedObjectValueType,
152255
>(
153256
source: SourceObjectType,

index.js

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,56 @@
11
const isObject = value => typeof value === 'object' && value !== null;
22

3-
// Customized for this use-case
4-
const isObjectCustom = value =>
5-
isObject(value)
6-
&& !(value instanceof RegExp)
7-
&& !(value instanceof Error)
8-
&& !(value instanceof Date)
9-
&& !(globalThis.Blob && value instanceof globalThis.Blob)
10-
&& typeof value.$$typeof !== 'symbol' // Jest asymmetric matchers
11-
&& typeof value.asymmetricMatch !== 'function'; // Jest matchers
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+
};
1234

1335
export const mapObjectSkip = Symbol('mapObjectSkip');
1436

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+
1547
const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => {
1648
const {
1749
target = {},
1850
...processOptions
1951
} = {
2052
deep: false,
53+
includeSymbols: false,
2154
...options,
2255
};
2356

@@ -28,17 +61,27 @@ const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => {
2861
isSeen.set(object, target);
2962

3063
const mapArray = array => array.map(element => isObjectCustom(element) ? _mapObject(element, mapper, processOptions, isSeen) : element);
64+
3165
if (Array.isArray(object)) {
3266
return mapArray(object);
3367
}
3468

35-
for (const [key, value] of Object.entries(object)) {
69+
for (const key of getEnumerableKeys(object, processOptions.includeSymbols)) {
70+
const value = object[key];
3671
const mapResult = mapper(key, value);
3772

3873
if (mapResult === mapObjectSkip) {
3974
continue;
4075
}
4176

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+
4285
let [newKey, newValue, {shouldRecurse = true} = {}] = mapResult;
4386

4487
// Drop `__proto__` keys.
@@ -52,7 +95,16 @@ const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => {
5295
: _mapObject(newValue, mapper, processOptions, isSeen);
5396
}
5497

55-
target[newKey] = newValue;
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+
}
56108
}
57109

58110
return target;

0 commit comments

Comments
 (0)