deep-case-crafter 2.1.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # DeepCaseCrafter
2
2
 
3
- **DeepCaseCrafter** transforms deeply nested object keys between case formats (`camelCase`, `PascalCase`, `snake_case`, `kebab-case`). It works on objects, arrays, Maps, and Sets, with configurable recursion depth, and produces precise output types when both `sourceCase` and `targetCase` are set.
4
-
5
3
  [![npm version](https://img.shields.io/npm/v/deep-case-crafter)](https://www.npmjs.com/package/deep-case-crafter)
6
4
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/deep-case-crafter)](https://bundlephobia.com/package/deep-case-crafter)
7
5
  [![npm type definitions](https://img.shields.io/npm/types/deep-case-crafter)](https://www.npmjs.com/package/deep-case-crafter)
@@ -10,14 +8,38 @@
10
8
  [![Maintenance](https://img.shields.io/maintenance/yes/2026.svg)](https://github.com/gyrgy/deep-case-crafter/graphs/commit-activity)
11
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/gyrgy/deep-case-crafter/blob/main/LICENSE)
12
10
 
11
+ Converts the keys of nested objects, arrays, Maps and Sets between camelCase,
12
+ snake_case, kebab-case and PascalCase, and types the result exactly.
13
+
14
+ ```typescript
15
+ import { transform } from 'deep-case-crafter';
16
+
17
+ // A record from a snake_case API
18
+ const apiUser = { user_id: 42, first_name: 'Ann', orders: [{ order_id: 7 }] };
19
+
20
+ const user = transform(apiUser);
21
+ // ^? { userId: number; firstName: string; orders: { orderId: number }[] }
22
+ console.log(user); // { userId: 42, firstName: 'Ann', orders: [{ orderId: 7 }] }
23
+
24
+ const body = transform(user, { targetCase: 'snake' });
25
+ // ^? { user_id: number; first_name: string; orders: { order_id: number }[] }
26
+ ```
27
+
13
28
  ## Features
14
29
 
15
- - Converts keys to `camelCase`, `PascalCase`, `snake_case`, and `kebab-case`.
16
- - **Acronym-aware**: `HTTPResponse` → `http_response`, `userID` → `user_id` — not `h_t_t_p_...`.
17
- - Works with deeply nested structures, including objects, arrays, Maps, and Sets.
18
- - Precise TypeScript inference when `sourceCase` and `targetCase` are set (otherwise the result is `unknown`).
19
- - Preserves special keys (`__`, `--`, leading numbers, special characters).
20
- - Configurable recursion depth (default: fully recursive).
30
+ - **Exact result types**: every key is typed the way it is converted at runtime, so
31
+ `user.firstName` is checked at compile time, without casts or type arguments (with a
32
+ few [exceptions](#result-types)).
33
+ - **No source case to declare**: every key's case is detected from the key itself, so
34
+ keys in different cases can be mixed in one input.
35
+ - **Acronym-aware**: `userID` → `user_id`, `HTTPResponse` → `http_response` or
36
+ `httpResponse`.
37
+ - **Deep**: objects, arrays, `Map` and `Set`, down to an optional `depth`, circular
38
+ references included. `Date`, `RegExp`, typed arrays and other built-in values are
39
+ returned untouched.
40
+ - **Keys that are data keep their names**: UUIDs always, and any other key whose path
41
+ you list in [`exclude`](#leaving-keys-unchanged), such as `'users_by_id.*'`.
42
+ - No dependencies, with ES module and CommonJS builds.
21
43
 
22
44
  ## Installation
23
45
 
@@ -25,11 +47,11 @@
25
47
  npm install deep-case-crafter
26
48
  ```
27
49
 
28
- > **⚠️ v2 breaking change:** `transform` is now a **named export**. Update
29
- > `import transform from 'deep-case-crafter'` to
30
- > `import { transform } from 'deep-case-crafter'` (CommonJS:
31
- > `const { transform } = require('deep-case-crafter')`). This fixes type
32
- > resolution for modern (`node16`/`nodenext`/bundler) consumers.
50
+ The type definitions require TypeScript 4.7 or later.
51
+
52
+ Upgrading from an earlier major version? The
53
+ [changelog](https://github.com/gyrgy/deep-case-crafter/blob/main/CHANGELOG.md) lists
54
+ what changed in each version and what to update.
33
55
 
34
56
  ---
35
57
 
@@ -42,12 +64,13 @@ import { transform } from 'deep-case-crafter';
42
64
 
43
65
  const input = { user_id: 1, first_name: 'John' };
44
66
  const result = transform(input);
67
+ // ^? { userId: number; firstName: string }
45
68
 
46
69
  console.log(result);
47
70
  // Output: { userId: 1, firstName: 'John' }
48
71
  ```
49
72
 
50
- _(Automatically converts from `snake_case` to `camelCase`)_
73
+ _(Automatically converts from `snake_case` to `camelCase`; the case of every key is detected from the key itself)_
51
74
 
52
75
  ---
53
76
 
@@ -67,16 +90,18 @@ console.log(kebabCaseResult);
67
90
 
68
91
  ---
69
92
 
70
- ### 3. Explicitly define `sourceCase` for TypeScript benefits
71
-
72
- _TypeScript infers the exact output keys when **both** `sourceCase` and `targetCase` are set._
93
+ ### 3. Name the result type with `TransformResult`
73
94
 
74
95
  ```typescript
75
- const input = { user_id: 1, first_name: 'John' };
76
- const result = transform(input, { targetCase: 'camel', sourceCase: 'snake' });
96
+ import { transform, type TransformResult } from 'deep-case-crafter';
77
97
 
78
- console.log(result);
79
- // Output: { userId: 1, firstName: 'John' }
98
+ type ApiUser = { user_id: number; first_name: string; created_at: Date };
99
+ type User = TransformResult<ApiUser>;
100
+ // { userId: number; firstName: string; createdAt: Date }
101
+
102
+ function toUser(apiUser: ApiUser): User {
103
+ return transform(apiUser);
104
+ }
80
105
  ```
81
106
 
82
107
  ---
@@ -89,10 +114,7 @@ const nestedInput = {
89
114
  posts: [{ post_id: 1, post_title: 'Hello' }],
90
115
  };
91
116
 
92
- const transformed = transform(nestedInput, {
93
- targetCase: 'camel',
94
- sourceCase: 'snake',
95
- });
117
+ const transformed = transform(nestedInput, { targetCase: 'camel' });
96
118
 
97
119
  console.log(transformed);
98
120
  // Output:
@@ -107,15 +129,12 @@ console.log(transformed);
107
129
  ### 5. Transforming a `Map`
108
130
 
109
131
  ```typescript
110
- const mapInput = new Map([
132
+ const mapInput = new Map<string, number | string>([
111
133
  ['user_id', 1],
112
134
  ['first_name', 'John'],
113
135
  ]);
114
136
 
115
- const result = transform(mapInput, {
116
- targetCase: 'camel',
117
- sourceCase: 'snake',
118
- });
137
+ const result = transform(mapInput, { targetCase: 'camel' });
119
138
 
120
139
  console.log(result.get('userId')); // 1
121
140
  console.log(result.get('firstName')); // 'John'
@@ -131,21 +150,61 @@ of objects nested inside the Set** are transformed.
131
150
 
132
151
  ```typescript
133
152
  const setInput = new Set([{ user_id: 1 }, { first_name: 'John' }]);
134
- const result = transform(setInput, {
135
- targetCase: 'camel',
136
- sourceCase: 'snake',
137
- });
153
+ const result = transform(setInput, { targetCase: 'camel' });
138
154
 
139
155
  console.log(result); // Set { { userId: 1 }, { firstName: 'John' } }
140
156
 
141
157
  // Primitive members are left as-is:
142
158
  const strings = new Set(['user_id', 'first_name']);
143
- console.log(transform(strings, { targetCase: 'camel', sourceCase: 'snake' }));
159
+ console.log(transform(strings, { targetCase: 'camel' }));
144
160
  // Set { 'user_id', 'first_name' }
145
161
  ```
146
162
 
147
163
  ---
148
164
 
165
+ ## How it compares
166
+
167
+ Checked in October 2026 against
168
+ [ts-case-convert](https://www.npmjs.com/package/ts-case-convert) 2.3.1 (the closest
169
+ typed alternative), [camelcase-keys](https://www.npmjs.com/package/camelcase-keys)
170
+ 10.0.3, [snakecase-keys](https://www.npmjs.com/package/snakecase-keys) 9.0.2 and
171
+ [es-toolkit](https://www.npmjs.com/package/es-toolkit) 1.53.0 (`toCamelCaseKeys`,
172
+ `toSnakeCaseKeys`). Plain snake_case keys such as `user_id` become the same camelCase
173
+ keys in all of them. The table shows where they differ:
174
+
175
+ | | deep-case-crafter | ts-case-convert | camelcase-keys, snakecase-keys | es-toolkit |
176
+ | -------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------- |
177
+ | `userID` to snake_case | `user_id` | `user_i_d` | `user_id` | `user_id`, typed `user_i_d` |
178
+ | `HTTPResponse` to snake_case | `http_response` | `h_t_t_p_response` | `http_response` | `http_response`, typed `h_t_t_p_response` |
179
+ | `isAValidUser` to snake_case | `is_a_valid_user` | `is_a_valid_user` | `is_a_valid_user` | `is_a_valid_user` |
180
+ | `addressLine1` to snake_case | `address_line1` | `address_line_1`, or `address_line1` with `no-split-numbers` | `address_line1` | `address_line_1`, typed `address_line1` |
181
+ | Objects inside a `Map` or `Set` | keys converted | outside its documented scope | outside their documented scope | left as they are, as documented |
182
+ | Result type for `await res.json()`, an `any` input | `any` | a union that includes `Date` and `null`, so `.userId` does not compile | `any` | `any` |
183
+ | A `readonly string[]` field in the result type | `readonly string[]` | an object type whose `.map()` does not compile | `readonly string[]` | an object type: `.map()` compiles, `[0]` and `for...of` do not |
184
+ | A `[number, number]` field in the result type | `[number, number]` | `number[]` | `[number, number]` | `number[]` |
185
+ | Leaving chosen keys unchanged | `exclude` paths, such as `'metadata.*'` | no option | `exclude` keys by name or RegExp; camelcase-keys also `stopPaths` | no option |
186
+ | Target cases | camel, snake, kebab, Pascal | camel, snake, Pascal, SCREAMING_SNAKE | camel and Pascal; snake | camel, snake, kebab, Pascal, SCREAMING_SNAKE |
187
+
188
+ Where the others are better or different:
189
+
190
+ - **Bundle size**: bundled and minified with esbuild, ts-case-convert's `objectToCamel`
191
+ and `objectToSnake` take about 1.1 KB gzipped, deep-case-crafter's `transform` about
192
+ 2.6 KB.
193
+ - **More output**: ts-case-convert and es-toolkit also convert to SCREAMING_SNAKE_CASE,
194
+ and both have functions for single strings: `toSnake('userId')` and
195
+ `snakeCase('userId')` return `'user_id'`. deep-case-crafter has neither.
196
+ - **Digits**: ts-case-convert separates digits by default (`addressLine1` →
197
+ `address_line_1`), so `address_line_1` → `addressLine1` → `address_line_1`
198
+ round-trips. Its `ts-case-convert/no-split-numbers` entry point keeps digits attached
199
+ instead, as deep-case-crafter always does: `addressLine1` → `address_line1`.
200
+ - **Acronyms**: ts-case-convert splits every capital, so `userID` → `user_i_d` comes back
201
+ as `userID`. deep-case-crafter's `user_id` comes back as `userId`.
202
+ - **Options**: camelcase-keys and snakecase-keys leave keys unchanged by name or RegExp
203
+ at any depth, and snakecase-keys accepts your own conversion function. camelcase-keys
204
+ converts only the top level unless you pass `deep: true`.
205
+
206
+ ---
207
+
149
208
  ## Acronym-aware conversion
150
209
 
151
210
  When converting **to** `snake_case` or `kebab-case`, runs of capitals (acronyms)
@@ -153,15 +212,56 @@ are treated as whole words instead of being split letter-by-letter:
153
212
 
154
213
  ```typescript
155
214
  const input = { getHTTPResponse: 1, userID: 2, parseHTMLString: 3 };
156
- const result = transform(input, { sourceCase: 'camel', targetCase: 'snake' });
215
+ const result = transform(input, { targetCase: 'snake' });
157
216
 
158
217
  console.log(result);
159
218
  // Output: { get_http_response: 1, user_id: 2, parse_html_string: 3 }
160
219
  // (not { get_h_t_t_p_response, user_i_d, ... })
161
220
  ```
162
221
 
163
- The output **types** match this exactly when `sourceCase` and `targetCase` are
164
- set, so `result.get_http_response` is known at compile time.
222
+ The last capital before a lowercase letter starts the next word, so a single capital
223
+ before a capitalised word is a one-letter word, in every target case:
224
+
225
+ ```typescript
226
+ const input = { isAValidUser: 1, XAxis: 2 };
227
+
228
+ console.log(transform(input, { targetCase: 'snake' }));
229
+ // Output: { is_a_valid_user: 1, x_axis: 2 }
230
+
231
+ console.log(transform(input, { targetCase: 'camel' }));
232
+ // Output: { isAValidUser: 1, xAxis: 2 }
233
+ ```
234
+
235
+ Such keys round-trip: `three_d_secure` → `threeDSecure` → `three_d_secure`. A key that
236
+ starts with two capitals before a lowercase letter is read the same way:
237
+ `OAuthToken` → `o_auth_token`, `IPv4` → `i_pv4`.
238
+
239
+ A plural `s` stays with its acronym: `allowedURLs` → `allowed_urls`,
240
+ `getURLsForIDs` → `get_urls_for_ids`, `IDs` → `ids`. An `s` word that follows an
241
+ acronym directly is read the same way, so `SSLIsEnabled` becomes `sslis_enabled`.
242
+
243
+ When converting `PascalCase` **to** `camelCase`, the first word is lowercased: a leading
244
+ acronym as a whole, a leading one-letter word on its own:
245
+
246
+ ```typescript
247
+ const input = { HTTPResponse: 1, UserID: 2, XAxis: 3, IDs: 4 };
248
+ const result = transform(input, { targetCase: 'camel' });
249
+
250
+ console.log(result);
251
+ // Output: { httpResponse: 1, userID: 2, xAxis: 3, ids: 4 }
252
+ ```
253
+
254
+ Words of two or more capitals, such as `ID`, `USD` or `GET`, are left unchanged, like
255
+ `SCREAMING_SNAKE` keys (see [Key Preservation Rules](#key-preservation-rules)). A single
256
+ capital, with or without digits (`S`, `S3`), is converted as a one-letter word.
257
+
258
+ Converting back to `PascalCase` cannot restore a lowercased acronym: `httpResponse`
259
+ becomes `HttpResponse`, and `ids` becomes `Ids`. In the other direction, a camelCase key
260
+ that starts with a one-letter word before an acronym does not come back either:
261
+ `iOSVersion` → `IOSVersion` → `iosVersion`.
262
+
263
+ The result **types** match all of this, so `result.get_http_response` and
264
+ `result.httpResponse` are known at compile time.
165
265
 
166
266
  ---
167
267
 
@@ -171,19 +271,92 @@ set, so `result.get_http_response` is known at compile time.
171
271
 
172
272
  - **data**: The data structure to transform (`object`, `array`, `Map`, or `Set`).
173
273
  - **options**:
174
- - `targetCase` (optional): `'camel' | 'snake' | 'pascal' | 'kebab'` (default: `'camel'`)
175
- - `sourceCase` (optional): `'snake' | 'camel' | 'pascal' | 'kebab'`. Omit it to auto-detect each key's case at runtime.
176
- - `depth` (optional): **Recursion depth** (default: `Infinity` — the whole structure is transformed). Pass a number to stop transforming below that level. Circular references are always handled safely.
274
+ - `targetCase` (optional): `'camel' | 'snake' | 'pascal' | 'kebab'` (default: `'camel'`, also used for any falsy value such as `''`, `null` or `0`). Any other value throws a `TypeError`.
275
+ - `depth` (optional): **How many nesting levels to transform** (default: `Infinity` — the whole structure; `null` means the same). `1` transforms only the top-level keys, `2` the keys one level down as well, and so on. The values of objects, Maps and Sets are one level deeper; array items stay on their array's level. A value that is not a number (`NaN`, `'3'`, `3n`) throws a `TypeError`.
276
+ - `exclude` (optional): **Keys to leave unchanged**, as paths of keys joined with dots, such as `['users_by_id.*', 'metadata.*']` (default: none; `null` means the same). See [Leaving keys unchanged](#leaving-keys-unchanged). Anything but an array of such paths throws a `TypeError`.
277
+ - `sourceCase` (**deprecated**): has no effect. Every key's case is detected from the key itself, at runtime and in the result type. It is still accepted, and so is the 2.x form with explicit type arguments, `transform<Data, 'snake', 'camel'>(data, { sourceCase: 'snake', targetCase: 'camel' })`, except together with `depth` or `exclude` (leave the type arguments out to use them); the key types follow the runtime, not `sourceCase` (see [3.0.0 in the changelog](https://github.com/gyrgy/deep-case-crafter/blob/main/CHANGELOG.md#300---2026-10-25)).
278
+
279
+ Circular references are resolved to the transformed object. With a finite `depth`, a
280
+ cycle is transformed once per level down to the limit instead: the result grows with the
281
+ depth, and a depth of more than about a thousand levels on cyclic data exceeds the call
282
+ stack and throws a `RangeError`. Omit `depth` for cyclic data, or keep it small.
283
+
284
+ ### Leaving keys unchanged
285
+
286
+ Keys that are data rather than field names, such as the IDs of a record, the keys of
287
+ free-form metadata, locale codes or Map keys, should keep their names. List their paths
288
+ in `exclude`:
289
+
290
+ ```typescript
291
+ const response = {
292
+ users_by_id: { abc_1: { first_name: 'Ann' } },
293
+ metadata: { order_ref: 'A1' },
294
+ };
177
295
 
178
- > **TypeScript inference:** precise output types are produced only when both `sourceCase` and `targetCase` are passed. Without both, the call still works at runtime (auto-detecting each key) but the result type is `unknown`.
296
+ const result = transform(response, {
297
+ exclude: ['users_by_id.*', 'metadata.*'],
298
+ });
299
+
300
+ console.log(result);
301
+ // Output: { usersById: { abc_1: { firstName: 'Ann' } }, metadata: { order_ref: 'A1' } }
302
+ ```
179
303
 
180
- > **`sourceCase` is a type-level assertion, not a runtime switch.** At runtime every
181
- > key's case is auto-detected regardless of `sourceCase`, so the precise types are
182
- > accurate only for keys genuinely well-formed in the declared source case. Ambiguous
183
- > keys — mixed case (`mixed_Case`), `SCREAMING_SNAKE`, or mixed separators (`a_b-c`) — are
184
- > **preserved unchanged at runtime**, even though the type may show them transformed. Set
185
- > `sourceCase` only when your keys really are that case. (Out-of-charset keys such as
186
- > `café_table` or `a.b_c` are preserved at both layers.)
304
+ - A path is the chain of keys that leads to a key in the input (the keys before they are
305
+ transformed), joined with `.`. A `*` in place of a key matches any key.
306
+ - A key keeps its name when its whole path matches: `metadata.*` keeps the keys of
307
+ `metadata`, not `metadata` itself, nor the keys further down. The value of a kept key
308
+ is still transformed, so `first_name` above becomes `firstName`.
309
+ - Array items and Set members add no key to a path: `orders.order_id` matches the
310
+ `order_id` of every item of an `orders` array. Map keys do: `prices.*` keeps the keys
311
+ of a `prices` Map, and `*` also matches Map keys that are not strings.
312
+ - A key that contains a `.` cannot be named in a path, but `*` matches it.
313
+ - `exclude` does not change `depth`: keys below the depth limit are left unchanged
314
+ anyway. An empty array is the same as no `exclude`.
315
+
316
+ The result type follows the paths when they are written inline or `as const`; paths only
317
+ known as `string[]` give an `unknown` result type.
318
+
319
+ ### Passing `transform` as a callback
320
+
321
+ Wrap it in a function: `.then((data) => transform(data))`,
322
+ `rows.map((row) => transform(row))`. Passed by name, `transform` gets an `unknown` result
323
+ type, because TypeScript then uses its last overload, `transform<Input, Output>`, whose
324
+ output it cannot infer, and `map` would pass each row's index as the options.
325
+
326
+ ### Result types
327
+
328
+ The result type follows the runtime: every key is typed the way it is transformed
329
+ (including the [preservation rules](#key-preservation-rules)), untouched values such as
330
+ `Date` keep their type, and `readonly` arrays, tuples, Maps and Sets keep their shape. Use
331
+ `TransformResult<Data, TargetCase, Depth, Exclude>` to name it, where `Exclude` is the
332
+ type of the `exclude` paths, such as `['metadata.*']`.
333
+
334
+ - A literal `depth` is applied to the type precisely. A depth only known as a `number` (a
335
+ variable, or `Infinity`) gives `unknown`, so omit `depth` to transform everything.
336
+ - `exclude` paths are applied to the type when they are literals, written inline or
337
+ stored `as const`. Paths only known as `string[]` give `unknown`, as a depth only known
338
+ as a `number` does.
339
+ - `targetCase` and `depth` may be string enum members, branded literals, unions, or come
340
+ from a stored options object: every options value they may hold gives its own result.
341
+ Options that are not known at all give `unknown`: an options object that may be
342
+ `undefined`, one typed as `TransformOptions` or `any`, one carrying other properties
343
+ (without `sourceCase`), and options passed with only the input type given
344
+ (`transform<Data>(data, options)`). Leave the type arguments out to get the precise
345
+ type.
346
+ - When a library exports a value or function built on `transform`, annotate it with
347
+ `TransformResult<...>`. Otherwise the result type is written out into its declaration
348
+ files: recursive types (trees, JSON values) are cut off as `any`, and very large types
349
+ or symbol-keyed members fail to compile (TS7056, TS4118). Generic functions around
350
+ `transform` are not affected.
351
+ - Some values cannot be told apart by their types. `Error`, `URL`, boxed primitives,
352
+ iterators, module namespace objects and other built-ins whose types do not declare a
353
+ `Symbol.toStringTag` are returned untouched, but typed as plain objects with
354
+ transformed keys. Class instances lose their prototype methods, getters and setters at
355
+ runtime, while their type still lists them. A class that implements `Map`, `Set`,
356
+ `ReadonlyMap` or `ReadonlySet` without extending `Map` or `Set` (and a Proxy around a
357
+ Map or Set) is typed as a transformed `Map` or `Set`, but at runtime it is returned
358
+ untouched when it has a `Symbol.toStringTag` (as every class implementing `Map` or
359
+ `Set` does), and rebuilt as a plain object otherwise.
187
360
 
188
361
  ---
189
362
 
@@ -198,13 +371,12 @@ const typedResult: Output = result; // TypeScript will enforce Output type here
198
371
 
199
372
  **Note:**
200
373
 
201
- - When using this overload, you cannot pass `sourceCase` in the options. If you do, TypeScript will show an error:
202
- > Object literal may only specify known properties, and 'sourceCase' does not exist in type 'Omit<TransformOptions, "sourceCase"> & { depth?: number }'.
374
+ - When using this overload, you cannot pass `sourceCase` in the options; TypeScript reports an error if you do.
203
375
  - This overload is useful when you want to take full control of the output type, but type safety between input, options, and output is not enforced by the library.
204
- - If you want to use `sourceCase` and benefit from type inference, use the standard overload:
376
+ - If you want the result type inferred, use the standard overload:
205
377
 
206
378
  ```typescript
207
- const result = transform(obj, { targetCase: 'camel', sourceCase: 'snake' }); // Output type is inferred
379
+ const result = transform(obj, { targetCase: 'camel' }); // Output type is inferred
208
380
  ```
209
381
 
210
382
  ---
@@ -216,12 +388,69 @@ A key is left **completely unchanged** when it:
216
388
  - **starts with anything other than a letter** (a digit or special character), e.g. `1st_place`, `_private`;
217
389
  - **ends with a non-alphanumeric character**, e.g. `value_`, `count-`;
218
390
  - **contains characters outside** `A-Z a-z 0-9 _ -`, e.g. `user.name`, `a@b`;
219
- - **contains consecutive `__` or `--`**, e.g. `a__b`, `x--y` (ambiguous to detect).
391
+ - **contains consecutive `__` or `--`**, e.g. `a__b`, `x--y` (ambiguous to detect);
392
+ - **is a UUID** (8-4-4-4-12 hexadecimal digits, in either case), e.g.
393
+ `f47ac10b-58cc-4372-a567-0e02b2c3d479`: it is data, not a name, so it is kept whether
394
+ it starts with a digit or a letter;
395
+ - **is not written in one recognisable case**, e.g. `mixed_Case`, `USER_ID`, `a_b-c`, or
396
+ is a word of two or more capitals, e.g. `ID`, `USD`, `B2B`.
220
397
 
221
398
  Trailing digits are kept but treated as part of the word, so they do **not** by
222
399
  themselves preserve a key — `address_1` becomes `address1`, and the single word
223
400
  `item2` stays `item2`.
224
401
 
402
+ These rules apply to `Map` keys and to the result types as well. To keep other keys
403
+ unchanged, such as IDs or the keys of free-form metadata, list their paths in
404
+ [`exclude`](#leaving-keys-unchanged).
405
+
406
+ ---
407
+
408
+ ## What Gets Transformed
409
+
410
+ | Value | Result |
411
+ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
412
+ | Plain objects, `Object.create(null)` objects | A new plain object with transformed keys |
413
+ | Class instances | A new plain object with their own enumerable properties (prototype methods are not copied) |
414
+ | Arrays | A new array with every item transformed |
415
+ | `Map`, including one from another realm | A new `Map` with string keys transformed and every value transformed |
416
+ | `Set`, including one from another realm | A new `Set` with every member transformed (primitive members are kept as-is) |
417
+ | Primitives, functions and built-ins such as `Date`, `RegExp`, `Error`, `URL`, `Promise`, typed arrays, `Buffer` | Returned untouched (the same reference) |
418
+ | Objects with a `Symbol.toStringTag` of their own or from their class, `arguments` objects, module namespaces, and Proxies around a Map or Set | Returned untouched (the same reference) |
419
+
420
+ Symbol-keyed properties are copied unchanged.
421
+
422
+ Worth knowing:
423
+
424
+ - If two keys of one object become the same key (`user_id` and `userId` both become
425
+ `userId`), the last one wins.
426
+ - Every property is read once, in order, and its value is transformed before the next one
427
+ is read; getters run during the transformation. Maps are read through their iterator.
428
+ - With `depth`, values below the limit are returned as they are: the result shares them
429
+ with the input instead of copying them. An object reached at several levels is
430
+ transformed once per level.
431
+ - With `exclude`, an object reached through several keys is transformed once for each
432
+ set of paths that match below them: one result when the same paths continue below
433
+ every key, one per set otherwise.
434
+ - The transformation is recursive: data nested about a thousand levels deep or more can
435
+ exceed the JavaScript call stack and throw an error (usually a `RangeError`).
436
+
437
+ ---
438
+
439
+ ## Versioning
440
+
441
+ deep-case-crafter follows [semantic versioning](https://semver.org). The v3 API is
442
+ stable. Key outputs change only to fix bugs, and every change is listed in the
443
+ [changelog](https://github.com/gyrgy/deep-case-crafter/blob/main/CHANGELOG.md): if you
444
+ store converted keys, such as database columns, check it before upgrading. A minor
445
+ version can add an option to `TransformOptions`, which `Required<TransformOptions>` then
446
+ requires.
447
+
448
+ ### Upgrading to v3
449
+
450
+ The changes from 2.x, and what to update, are listed under
451
+ [3.0.0](https://github.com/gyrgy/deep-case-crafter/blob/main/CHANGELOG.md#300---2026-10-25)
452
+ in the changelog.
453
+
225
454
  ---
226
455
 
227
456
  ## Contributing
@@ -229,7 +458,9 @@ themselves preserve a key — `address_1` becomes `address1`, and the single wor
229
458
  1. Fork the repository.
230
459
  2. Create a new branch (`feature/my-feature`).
231
460
  3. Commit your changes.
232
- 4. Submit a pull request.
461
+ 4. Run `npm run check` (lint, formatting, types, tests with coverage, build and the
462
+ published types).
463
+ 5. Submit a pull request.
233
464
 
234
465
  ---
235
466
 
@@ -243,4 +474,6 @@ MIT
243
474
 
244
475
  - [NPM Package](https://www.npmjs.com/package/deep-case-crafter)
245
476
  - [GitHub Repository](https://github.com/gyrgy/deep-case-crafter)
477
+ - [Changelog](https://github.com/gyrgy/deep-case-crafter/blob/main/CHANGELOG.md)
478
+ - [Security Policy](https://github.com/gyrgy/deep-case-crafter/blob/main/SECURITY.md)
246
479
  - [TypeScript Docs](https://www.typescriptlang.org/)