fast-json-stringify 7.2.1 → 7.3.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
@@ -135,9 +135,8 @@ are rejected before compilation.
135
135
  - `inlineValidators`: when using standalone mode, embed Ajv-generated validator functions in the output instead of compiling schemas at runtime. [More details](#standalone)
136
136
  - `largeArrayMechanism`: set the mechanism that should be used to handle large
137
137
  (by default `20000` or more items) arrays. [More details](#largearrays)
138
- - `compileValidators`: when `true`, the `ajv` validators used by `anyOf`, `oneOf` and
139
- `if/then/else` are compiled during `build()` instead of lazily, on the first serialization
140
- that reaches them. This makes `build()` slower but removes a potentially large one-off cost
138
+ - `objectMechanism`: set the mechanism used to serialize objects. `'native'` projects the input into schema-shaped scratch objects and delegates to the native `JSON.stringify`, which is much faster on Node.js 25+. [More details](#objectmechanism)
139
+ - `compileValidators`: when `true`, the `ajv` validators used by `anyOf`, `oneOf` and `if/then/else` are compiled during `build()` instead of lazily, on the first serialization that reaches them. This makes `build()` slower but removes a potentially large one-off cost
141
140
  from the first `stringify()` call. Defaults to `false`.
142
141
 
143
142
 
@@ -673,6 +672,52 @@ In [standalone mode](#standalone) the cutoffs are baked into the generated
673
672
  code, so the generated module inherits the cutoffs of the Node.js version that
674
673
  ran `build()`, not of the one that runs the module.
675
674
 
675
+ <a name="objectmechanism"></a>
676
+ #### Object mechanism
677
+
678
+ By default every object is serialized by generated code that appends the JSON
679
+ text property by property. Since V8 13.8 (Node.js 25+) the native
680
+ `JSON.stringify` has a side-effect-free fast path that no JavaScript string
681
+ building can match, but that fast path bails out as soon as the input carries a
682
+ `Date`, a `toJSON`, a class prototype or any property that is not plain data.
683
+
684
+ With `objectMechanism: 'native'`, `fast-json-stringify` instead *projects* the
685
+ input into preallocated scratch objects whose keys are exactly the schema
686
+ properties, in schema order, and hands the scratch to the native
687
+ `JSON.stringify`. The scratch is plain by construction, so the fast path is
688
+ always taken, whatever the input looks like, while the schema is still applied:
689
+ undeclared properties are dropped, `required` is enforced, `default` applied,
690
+ types coerced and `format: date-time` / `date` / `time` rendered as before.
691
+ The output is byte-identical to the default mechanism.
692
+
693
+ ```js
694
+ const stringify = fastJson(schema, { objectMechanism: 'native' })
695
+ ```
696
+
697
+ The projection covers schemas made only of:
698
+
699
+ - `object` with `properties` (no `additionalProperties`, `patternProperties`,
700
+ `nullable`, `dependencies`, combinators or `$ref`), with at least one property;
701
+ - `string` (optionally with `format: date-time`, `date` or `time`), `number`,
702
+ `integer`, `boolean`, without `nullable`, `const`, `enum`, combinators or `$ref`;
703
+ - the two-element forms `['string', 'null']`, `['number', 'null']` and
704
+ `['boolean', 'null']` (in either order), with the same strict type dispatch
705
+ as the default mechanism;
706
+ - `array` whose `items` is one such schema (no tuples), nested to any depth;
707
+ every array element gets its own scratch objects.
708
+
709
+ Any other schema silently uses the default mechanism, so the option is always
710
+ safe to enable. The projection only pays off where V8 has the fast path, so on
711
+ Node.js 24 and older the option is a no-op and the default mechanism is used.
712
+ On Node.js 25+ the projection serializes plain objects within 5-15% of a bare
713
+ `JSON.stringify` (the default mechanism is 35-50% behind it there), and objects
714
+ coming from an ORM (extra columns, `Date` instances, class prototypes) several
715
+ times faster than both the default mechanism and `JSON.stringify` itself.
716
+
717
+ The scratch objects are reused across calls; `JSON.stringify` is synchronous,
718
+ so this is safe, and no reference to the last input is retained beyond the
719
+ primitive values written into the scratch.
720
+
676
721
  <a name="unsafe"></a>
677
722
  #### Unsafe string
678
723
  By default, the library escapes all strings. With the 'unsafe' format, the string isn't escaped. This has a potentially dangerous security issue. You can use it only if you are sure that your data doesn't need escaping. The advantage is a significant performance improvement.
@@ -114,6 +114,7 @@ const CJSStringifyString = CJS({ type: 'string' })
114
114
 
115
115
  const FJS = require('..')
116
116
  const stringify = FJS(schema)
117
+ const stringifyNative = FJS(schema, { objectMechanism: 'native' })
117
118
  const stringifyArrayDefault = FJS(arraySchema)
118
119
  const stringifyArrayJSONStringify = FJS(arraySchema, {
119
120
  largeArrayMechanism: 'json-stringify'
@@ -282,6 +283,10 @@ suite.add('fast-json-stringify: obj', function () {
282
283
  Buffer.byteLength(stringify(obj))
283
284
  })
284
285
 
286
+ suite.add('fast-json-stringify [native]: obj', function () {
287
+ Buffer.byteLength(stringifyNative(obj))
288
+ })
289
+
285
290
  suite.add('json-accelerator: obj', function () {
286
291
  Buffer.byteLength(accelStringify(obj))
287
292
  })
@@ -312,8 +317,10 @@ suite.add('compile-json-stringify: date', function () {
312
317
 
313
318
  for (const { name, schema: responseSchema, input } of apiResponses) {
314
319
  const stringifyResponse = FJS(responseSchema)
320
+ const stringifyResponseNative = FJS(responseSchema, { objectMechanism: 'native' })
315
321
  // Check outside the timed loop that the schema preserves the complete response.
316
322
  assert.deepStrictEqual(JSON.parse(stringifyResponse(input)), JSON.parse(JSON.stringify(input)))
323
+ assert.equal(stringifyResponseNative(input), stringifyResponse(input))
317
324
 
318
325
  suite.add(`JSON.stringify: ${name}`, function () {
319
326
  Buffer.byteLength(JSON.stringify(input))
@@ -321,6 +328,9 @@ for (const { name, schema: responseSchema, input } of apiResponses) {
321
328
  suite.add(`fast-json-stringify: ${name}`, function () {
322
329
  Buffer.byteLength(stringifyResponse(input))
323
330
  })
331
+ suite.add(`fast-json-stringify [native]: ${name}`, function () {
332
+ Buffer.byteLength(stringifyResponseNative(input))
333
+ })
324
334
  }
325
335
 
326
336
  if (process.argv.includes('--fjs-vs-json')) {
@@ -300,6 +300,80 @@ const benchmarks = [
300
300
  },
301
301
  input: { firstName: 'Max', lastName: 'Power', age: 22 }
302
302
  },
303
+ {
304
+ name: 'nested object with array and required',
305
+ schema: {
306
+ type: 'object',
307
+ required: ['id'],
308
+ properties: {
309
+ id: { type: 'integer' },
310
+ name: { type: 'string' },
311
+ email: { type: 'string' },
312
+ active: { type: 'boolean' },
313
+ score: { type: 'number' },
314
+ tags: { type: 'array', items: { type: 'string' } },
315
+ address: {
316
+ type: 'object',
317
+ properties: {
318
+ street: { type: 'string' },
319
+ city: { type: 'string' },
320
+ zip: { type: 'string' }
321
+ }
322
+ }
323
+ }
324
+ },
325
+ input: { id: 1, name: 'John Doe', email: 'john@example.com', active: true, score: 42.195, tags: ['a', 'b', 'c'], address: { street: '1 Example Street', city: 'London', zip: 'SW1A 1AA' } }
326
+ },
327
+ {
328
+ name: 'nested object with array and required (objectMechanism: native)',
329
+ schema: {
330
+ type: 'object',
331
+ required: ['id'],
332
+ properties: {
333
+ id: { type: 'integer' },
334
+ name: { type: 'string' },
335
+ email: { type: 'string' },
336
+ active: { type: 'boolean' },
337
+ score: { type: 'number' },
338
+ tags: { type: 'array', items: { type: 'string' } },
339
+ address: {
340
+ type: 'object',
341
+ properties: {
342
+ street: { type: 'string' },
343
+ city: { type: 'string' },
344
+ zip: { type: 'string' }
345
+ }
346
+ }
347
+ }
348
+ },
349
+ options: { objectMechanism: 'native' },
350
+ input: { id: 1, name: 'John Doe', email: 'john@example.com', active: true, score: 42.195, tags: ['a', 'b', 'c'], address: { street: '1 Example Street', city: 'London', zip: 'SW1A 1AA' } }
351
+ },
352
+ {
353
+ name: 'nested object with extra props and Date (objectMechanism: native)',
354
+ schema: {
355
+ type: 'object',
356
+ required: ['id'],
357
+ properties: {
358
+ id: { type: 'integer' },
359
+ name: { type: 'string' },
360
+ email: { type: 'string' },
361
+ active: { type: 'boolean' },
362
+ score: { type: 'number' },
363
+ tags: { type: 'array', items: { type: 'string' } },
364
+ address: {
365
+ type: 'object',
366
+ properties: {
367
+ street: { type: 'string' },
368
+ city: { type: 'string' },
369
+ zip: { type: 'string' }
370
+ }
371
+ }
372
+ }
373
+ },
374
+ options: { objectMechanism: 'native' },
375
+ input: { id: 1, name: 'John Doe', email: 'john@example.com', active: true, score: 42.195, tags: ['a', 'b', 'c'], address: { street: '1 Example Street', city: 'London', zip: 'SW1A 1AA' }, passwordHash: 'x', createdAt: new Date(0) }
376
+ },
303
377
  {
304
378
  name: 'oneOf external refs compile and first stringify',
305
379
  compile: true,
package/index.js CHANGED
@@ -7,6 +7,7 @@ const { MergeError } = require('@fastify/merge-json-schemas')
7
7
 
8
8
  const Serializer = require('./lib/serializer')
9
9
  const Validator = require('./lib/validator')
10
+ const { buildProjection } = require('./lib/projection')
10
11
  const Location = require('./lib/location')
11
12
  const validate = require('./lib/schema-validator')
12
13
  const mergeSchemas = require('./lib/merge-schemas')
@@ -41,6 +42,9 @@ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
41
42
  // TODO: drop the Node 20 tier (and this version check) once Node 20 is no
42
43
  // longer supported.
43
44
  const nodeMajor = Number.parseInt(process.versions.node, 10)
45
+ // V8 13.8 (Node 25+) introduced the side-effect-free JSON.stringify fast path
46
+ // that objectMechanism: 'native' relies on.
47
+ const PROJECTION_SUPPORTED = nodeMajor >= 25
44
48
  const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5, string: 128 }, { integer: 512, number: 128, string: 8 }][+(nodeMajor >= 22)]
45
49
  const STRING_ARRAY_FAST_PATH_MIN_LENGTH = PRIMITIVE_FAST_PATH_MIN_LENGTH.string
46
50
  const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
@@ -97,6 +101,11 @@ const validLargeArrayMechanisms = new Set([
97
101
  'json-stringify'
98
102
  ])
99
103
 
104
+ const validObjectMechanisms = new Set([
105
+ 'default',
106
+ 'native'
107
+ ])
108
+
100
109
  // Keywords that do not change the output, so a $ref carrying only these
101
110
  // can still be dereferenced directly instead of merged with its target.
102
111
  const IGNORED_REF_SIBLING_KEYWORDS = new Set([
@@ -442,6 +451,7 @@ function build (schema, options) {
442
451
  buildingSet: new Set(),
443
452
  largeArraySize: 2e4,
444
453
  largeArrayMechanism: 'default',
454
+ objectMechanism: 'default',
445
455
  uid: 0
446
456
  }
447
457
 
@@ -479,6 +489,14 @@ function build (schema, options) {
479
489
  }
480
490
  }
481
491
 
492
+ if (options.objectMechanism) {
493
+ if (validObjectMechanisms.has(options.objectMechanism)) {
494
+ context.objectMechanism = options.objectMechanism
495
+ } else {
496
+ throw new Error(`Unsupported object mechanism ${options.objectMechanism}`)
497
+ }
498
+ }
499
+
482
500
  if (options.largeArraySize) {
483
501
  const largeArraySizeType = typeof options.largeArraySize
484
502
  let parsedNumber
@@ -500,7 +518,24 @@ function build (schema, options) {
500
518
  if (hasRef) {
501
519
  detectRecursiveSchemas(context, location)
502
520
  }
503
- const code = buildValue(context, location, 'input')
521
+
522
+ // objectMechanism: 'native' projects the input into shape-stable scratch
523
+ // objects and delegates to the native JSON.stringify. Only whole schemas
524
+ // made of objects, arrays and primitives qualify; anything else falls back
525
+ // to the string codegen below, so output never changes for unsupported shapes.
526
+ // The projection only pays off where V8 has the side-effect-free
527
+ // JSON.stringify fast path (V8 13.8+, Node 25+): on older runtimes the
528
+ // string codegen is up to 2x faster, so the option is a no-op there.
529
+ let code = null
530
+ let projection = null
531
+ // `forceProjection` is an internal, undocumented escape hatch used by the
532
+ // test suite to exercise the projection on runtimes where it is gated off.
533
+ if (context.objectMechanism === 'native' && (options.forceProjection === true || PROJECTION_SUPPORTED)) {
534
+ projection = buildProjection(schema)
535
+ }
536
+ if (projection === null) {
537
+ code = buildValue(context, location, 'input')
538
+ }
504
539
 
505
540
  let contextFunctionCode = `
506
541
  ${serializerFns}
@@ -522,7 +557,9 @@ function build (schema, options) {
522
557
  // and the overhead of the intermediate variable 'json'. We can avoid the
523
558
  // wrapping and the unnecessary memory allocation by aliasing 'anonymous0' to
524
559
  // 'main'
525
- if (code === 'json += anonymous0(input)') {
560
+ if (projection !== null) {
561
+ contextFunctionCode += projection
562
+ } else if (code === 'json += anonymous0(input)') {
526
563
  contextFunctionCode += `
527
564
  ${context.functions.join('\n')}
528
565
  const main = anonymous0
@@ -0,0 +1,325 @@
1
+ 'use strict'
2
+
3
+ // Projection strategy (objectMechanism: 'native').
4
+ //
5
+ // Instead of building the JSON string in generated JS, project the input into
6
+ // preallocated "scratch" objects whose keys are exactly the schema properties,
7
+ // in schema order, and hand the scratch to the native JSON.stringify. The
8
+ // scratch is clean by construction (no extra properties, no Date, no toJSON,
9
+ // standard prototypes), so V8's side-effect-free fast path (V8 13.8+) is
10
+ // always taken, even when the user's input would have bailed out of it.
11
+ //
12
+ // Projectable schemas (every node must be one of these):
13
+ // - object with `properties` only: no additionalProperties, patternProperties,
14
+ // nullable, combinators, $ref, dependencies; at least one property;
15
+ // - string / number / integer / boolean without nullable, const, enum,
16
+ // combinators, $ref; string may have format date-time / date / time;
17
+ // - the two-element forms ['string'|'number'|'boolean', 'null'] (any order),
18
+ // with the strict no-coercion dispatch of the multi-type codegen;
19
+ // ['integer', 'null'] is left to the codegen (its null handling depends
20
+ // on the type order);
21
+ // - array whose `items` is a single projectable schema (not a tuple); array
22
+ // elements get their own scratch tree from a per-element slot pool.
23
+ // Anything else makes buildProjection return null and the caller falls back to
24
+ // the string codegen, so the option never changes output for unsupported
25
+ // schemas.
26
+ //
27
+ // Semantics match the string codegen byte for byte: input.toJSON() honoured,
28
+ // `required` missing throws, `undefined` omitted, `default` applied, null
29
+ // string -> "", null number -> 0, null object -> {}, Date in a string -> ISO,
30
+ // numbers via the asNumber/asInteger rules, booleans via truthiness.
31
+
32
+ const PRIMITIVES = new Set(['string', 'number', 'integer', 'boolean'])
33
+ const STRING_FORMATS = new Set([undefined, 'date-time', 'date', 'time'])
34
+
35
+ // ['string'|'number'|'boolean', 'null'] in either order -> the non-null type;
36
+ // anything else -> null. Also requires the format rules of the base type.
37
+ function nullableMultiType (schema) {
38
+ const t = schema.type
39
+ if (!Array.isArray(t) || t.length !== 2 || !t.includes('null')) return null
40
+ const base = t[0] === 'null' ? t[1] : t[0]
41
+ if (base !== 'string' && base !== 'number' && base !== 'boolean') return null
42
+ if (base === 'string' ? !STRING_FORMATS.has(schema.format) : schema.format !== undefined) return null
43
+ return base
44
+ }
45
+
46
+ function hasDispatchKeyword (s) {
47
+ return s.nullable === true || s.const !== undefined || s.enum !== undefined ||
48
+ s.allOf !== undefined || s.anyOf !== undefined || s.oneOf !== undefined ||
49
+ s.if !== undefined || s.$ref !== undefined
50
+ }
51
+
52
+ function isProjectable (schema, depth = 0) {
53
+ if (typeof schema !== 'object' || schema === null || depth > 32) return false
54
+ if (hasDispatchKeyword(schema)) return false
55
+ if (Array.isArray(schema.type)) return nullableMultiType(schema) !== null
56
+
57
+ if (schema.type === 'object') {
58
+ if (schema.additionalProperties !== undefined || schema.patternProperties !== undefined ||
59
+ schema.dependencies !== undefined ||
60
+ typeof schema.properties !== 'object' || schema.properties === null) {
61
+ return false
62
+ }
63
+ const keys = Object.keys(schema.properties)
64
+ if (keys.length === 0) return false
65
+ for (const key of keys) {
66
+ if (!isProjectable(schema.properties[key], depth + 1)) return false
67
+ }
68
+ return true
69
+ }
70
+
71
+ if (schema.type === 'array') {
72
+ if (schema.items === undefined || Array.isArray(schema.items) || schema.additionalItems !== undefined) return false
73
+ return isProjectable(schema.items, depth + 1)
74
+ }
75
+
76
+ if (!PRIMITIVES.has(schema.type)) return false
77
+ if (schema.type === 'string') return STRING_FORMATS.has(schema.format)
78
+ return schema.format === undefined
79
+ }
80
+
81
+ function primitiveExpr (schema, src, pointerIdx) {
82
+ const base = nullableMultiType(schema)
83
+ if (base !== null) {
84
+ // Same acceptance rules as the multi-type codegen (buildMultiTypeSerializer):
85
+ // null passes through; a matching primitive goes through the single-type
86
+ // conversion; anything else is a TypeError. No coercion across types.
87
+ const err = `typeMismatch(${pointerIdx})`
88
+ const single = primitiveExpr({ ...schema, type: base }, src, pointerIdx)
89
+ if (base === 'string') {
90
+ return `(${src} === null ? null : (typeof ${src} === 'string' || ${src} instanceof Date || ${src} instanceof RegExp || (typeof ${src} === 'object' && typeof ${src}.toString === 'function' && ${src}.toString !== Object.prototype.toString)) ? ${single} : ${err})`
91
+ }
92
+ if (base === 'number') {
93
+ return `(${src} === null ? null : typeof ${src} === 'number' ? ${single} : ${err})`
94
+ }
95
+ return `(${src} === null ? null : typeof ${src} === 'boolean' ? ${src} : ${err})`
96
+ }
97
+ switch (schema.type) {
98
+ case 'string':
99
+ if (schema.format === 'date-time') return `asDateTimeValue(${src})`
100
+ if (schema.format === 'date') return `asDateValue(${src})`
101
+ if (schema.format === 'time') return `asTimeValue(${src})`
102
+ return `asStringValue(${src})`
103
+ case 'number': return `asNumberValue(${src})`
104
+ case 'integer': return `asIntegerValue(${src})`
105
+ case 'boolean': return `(${src} ? true : false)`
106
+ }
107
+ }
108
+
109
+ // Same order as the string codegen: required properties first (stable sort).
110
+ function orderedKeys (schema) {
111
+ const required = schema.required || []
112
+ return Object.keys(schema.properties).sort((a, b) => {
113
+ const ra = required.includes(a); const rb = required.includes(b)
114
+ return ra === rb ? 0 : ra ? -1 : 1
115
+ })
116
+ }
117
+
118
+ class Emitter {
119
+ constructor () {
120
+ this.decls = []
121
+ this.uid = 0
122
+ // JSON pointers for error messages. The generated code only ever refers
123
+ // to them by index into this table (emitted as a JSON literal), so no
124
+ // schema-derived text is interpolated into the code itself.
125
+ this.pointers = []
126
+ this.keys = []
127
+ }
128
+
129
+ id (prefix) { return `${prefix}_${this.uid++}` }
130
+
131
+ pointerIndex (pointer) {
132
+ let i = this.pointers.indexOf(pointer)
133
+ if (i === -1) { i = this.pointers.length; this.pointers.push(pointer) }
134
+ return i
135
+ }
136
+
137
+ keyIndex (key) {
138
+ let i = this.keys.indexOf(key)
139
+ if (i === -1) { i = this.keys.length; this.keys.push(key) }
140
+ return i
141
+ }
142
+
143
+ scratchLiteral (schema) {
144
+ return '{ ' + orderedKeys(schema).map((k) => `${JSON.stringify(k)}: undefined`).join(', ') + ' }'
145
+ }
146
+
147
+ // Scratch storage for a schema node. Nodes reachable without crossing an
148
+ // array are module-level constants (one per serializer). Nodes inside array
149
+ // items live in a per-slot holder object created by a generated factory, so
150
+ // that each array element has its own scratch tree.
151
+ //
152
+ // `holder` is null at the top level (declare constants) or the expression of
153
+ // the current slot holder; `slotFields` collects the holder's fields.
154
+ allocScratch (schema, holder, slotFields) {
155
+ if (holder === null) {
156
+ const name = this.id('scratch')
157
+ this.decls.push(`const ${name} = ${this.scratchLiteral(schema)}`)
158
+ return name
159
+ }
160
+ const field = this.id('s')
161
+ slotFields.push([field, this.scratchLiteral(schema)])
162
+ return `${holder}.${field}`
163
+ }
164
+
165
+ allocArray (holder, slotFields) {
166
+ if (holder === null) {
167
+ const name = this.id('arr')
168
+ this.decls.push(`const ${name} = []`)
169
+ return name
170
+ }
171
+ const field = this.id('a')
172
+ slotFields.push([field, '[]'])
173
+ return `${holder}.${field}`
174
+ }
175
+
176
+ fillObject (schema, src, dst, out, indent, pointer, holder, slotFields) {
177
+ const props = schema.properties
178
+ const requiredList = schema.required || []
179
+ const required = new Set(requiredList)
180
+ const keys = orderedKeys(schema)
181
+ const o = this.id('o')
182
+ out.push(`${indent}const ${o} = (${src} !== null && typeof ${src} === 'object' && typeof ${src}.toJSON === 'function') ? ${src}.toJSON() : ${src}`)
183
+ out.push(`${indent}if (${o} === null) {`)
184
+ for (const k of keys) out.push(`${indent} ${dst}[${JSON.stringify(k)}] = undefined`)
185
+ out.push(`${indent}} else {`)
186
+ for (const k of requiredList) {
187
+ if (props[k] === undefined) {
188
+ out.push(`${indent} if (${o}[${JSON.stringify(k)}] === undefined) requiredMissing(${this.keyIndex(k)})`)
189
+ }
190
+ }
191
+ for (const k of keys) {
192
+ const p = props[k]
193
+ const ks = JSON.stringify(k)
194
+ const v = this.id('v')
195
+ out.push(`${indent} const ${v} = ${o}[${ks}]`)
196
+ out.push(`${indent} if (${v} === undefined) {`)
197
+ if (required.has(k)) {
198
+ out.push(`${indent} requiredMissing(${this.keyIndex(k)})`)
199
+ } else if (p.default !== undefined) {
200
+ out.push(`${indent} ${dst}[${ks}] = ${JSON.stringify(p.default)}`)
201
+ } else {
202
+ out.push(`${indent} ${dst}[${ks}] = undefined`)
203
+ }
204
+ out.push(`${indent} } else {`)
205
+ out.push(`${indent} ${dst}[${ks}] = ${this.valueExpr(p, v, out, indent + ' ', `${pointer}/properties/${k}`, holder, slotFields)}`)
206
+ out.push(`${indent} }`)
207
+ }
208
+ out.push(`${indent}}`)
209
+ }
210
+
211
+ // Returns an expression for the projected value of `src`; may push
212
+ // preparatory statements to `out` (filling a nested scratch) first.
213
+ valueExpr (schema, src, out, indent, pointer, holder, slotFields) {
214
+ if (schema.type === 'object') {
215
+ const scratch = this.allocScratch(schema, holder, slotFields)
216
+ this.fillObject(schema, src, scratch, out, indent, pointer, holder, slotFields)
217
+ return scratch
218
+ }
219
+ if (schema.type === 'array') {
220
+ const pool = this.allocArray(holder, slotFields)
221
+ this.fillArray(schema, src, pool, out, indent, pointer, holder, slotFields)
222
+ return pool
223
+ }
224
+ return primitiveExpr(schema, src, this.pointerIndex(pointer))
225
+ }
226
+
227
+ fillArray (schema, src, pool, out, indent, pointer, holder, slotFields) {
228
+ const items = schema.items
229
+ const n = this.id('n')
230
+ const i = this.id('i')
231
+ out.push(`${indent}if (${src} === null) {`)
232
+ out.push(`${indent} ${pool}.length = 0`)
233
+ out.push(`${indent}} else {`)
234
+ out.push(`${indent} if (!Array.isArray(${src})) typeMismatch(${this.pointerIndex(pointer)})`)
235
+ out.push(`${indent} const ${n} = ${src}.length`)
236
+ out.push(`${indent} ${pool}.length = ${n}`)
237
+ if (items.type === 'object' || items.type === 'array') {
238
+ // Pool of per-element slot holders; each holder owns the scratch tree of
239
+ // one element (nested objects and arrays included). Grows to the longest
240
+ // array seen, never shrinks. When this array is itself inside an array
241
+ // element, its slot pool lives in the enclosing holder, so sibling
242
+ // elements never share scratch objects.
243
+ const slots = this.allocArray(holder, slotFields)
244
+ const factory = this.id('makeSlot')
245
+ const el = this.id('el')
246
+ const innerHolder = this.id('slot')
247
+ const innerFields = []
248
+ const inner = []
249
+ const expr = this.valueExpr(items, el, inner, indent + ' ', `${pointer}/items`, innerHolder, innerFields)
250
+ this.decls.push(`function ${factory} () { return { ${innerFields.map(([f, lit]) => `${f}: ${lit}`).join(', ')} } }`)
251
+ out.push(`${indent} for (let ${i} = ${slots}.length; ${i} < ${n}; ${i}++) ${slots}[${i}] = ${factory}()`)
252
+ out.push(`${indent} for (let ${i} = 0; ${i} < ${n}; ${i}++) {`)
253
+ out.push(`${indent} const ${el} = ${src}[${i}]`)
254
+ out.push(`${indent} const ${innerHolder} = ${slots}[${i}]`)
255
+ out.push(...inner)
256
+ out.push(`${indent} ${pool}[${i}] = ${expr}`)
257
+ out.push(`${indent} }`)
258
+ } else {
259
+ out.push(`${indent} for (let ${i} = 0; ${i} < ${n}; ${i}++) {`)
260
+ out.push(`${indent} ${pool}[${i}] = ${primitiveExpr(items, `${src}[${i}]`, this.pointerIndex(`${pointer}/items`))}`)
261
+ out.push(`${indent} }`)
262
+ }
263
+ out.push(`${indent}}`)
264
+ }
265
+ }
266
+
267
+ // Returns the full body of the context function (declarations + main) or null
268
+ // when the schema is not projectable.
269
+ function buildProjection (schema) {
270
+ if (!isProjectable(schema)) return null
271
+ if (schema.type !== 'object' && schema.type !== 'array') return null // primitive roots gain nothing
272
+ const em = new Emitter()
273
+ const body = []
274
+ const rootExpr = em.valueExpr(schema, 'input', body, ' ', '#', null, null)
275
+ // root object: a null input serializes as {} (same as the codegen)
276
+ body.push(` return JSON.stringify(${rootExpr})`)
277
+ return `
278
+ ${projectionHelpers}
279
+ const POINTERS = ${JSON.stringify(em.pointers)}
280
+ const KEYS = ${JSON.stringify(em.keys)}
281
+ function typeMismatch (i) {
282
+ throw new TypeError("The value of '" + POINTERS[i] + "' does not match schema definition.")
283
+ }
284
+ function requiredMissing (i) {
285
+ throw new Error('"' + KEYS[i] + '" is required!')
286
+ }
287
+ ${em.decls.join('\n ')}
288
+ function main (input) {
289
+ ${body.join('\n')}
290
+ }
291
+ return main
292
+ `
293
+ }
294
+
295
+ // Runtime helpers mirroring lib/serializer.js, returning values (not JSON text)
296
+ // to be placed into the scratch.
297
+ const projectionHelpers = `
298
+ const asStringValue = (v) => {
299
+ if (typeof v === 'string') return v
300
+ if (v === null) return ''
301
+ if (v instanceof Date) return v.toISOString()
302
+ if (v instanceof RegExp) return v.source
303
+ return v.toString()
304
+ }
305
+ const asDateTimeValue = (v) => {
306
+ if (v === null) return ''
307
+ if (v instanceof Date) return v.toISOString()
308
+ if (typeof v === 'string') return v
309
+ return v.toString()
310
+ }
311
+ const asDateValue = (v) => { const s = serializer.asDate(v); return s.slice(1, -1) }
312
+ const asTimeValue = (v) => { const s = serializer.asTime(v); return s.slice(1, -1) }
313
+ const asNumberValue = (v) => {
314
+ const num = typeof v === 'number' ? v : Number(v)
315
+ if (num !== num) throw new Error('The value "' + v + '" cannot be converted to a number.')
316
+ return (num === Infinity || num === -Infinity) ? null : num
317
+ }
318
+ const asIntegerValue = (v) => {
319
+ if (Number.isInteger(v)) return v
320
+ if (typeof v === 'bigint') return Number(v)
321
+ return Number(serializer.asInteger(v))
322
+ }
323
+ `
324
+
325
+ module.exports = { buildProjection, isProjectable }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.2.1",
3
+ "version": "7.3.0",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -0,0 +1,18 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+
5
+ // objectMechanism: 'native' is a no-op on runtimes without the V8 JSON.stringify
6
+ // fast path (Node < 25).
7
+ const build = require('..')
8
+
9
+ const schema = { type: 'object', properties: { a: { type: 'string' }, n: { type: 'number' } } }
10
+ const nodeMajor = Number.parseInt(process.versions.node, 10)
11
+
12
+ test('native mechanism is enabled only on Node 25+', t => {
13
+ t.plan(2)
14
+ const code = build(schema, { objectMechanism: 'native', mode: 'debug' }).code
15
+ t.assert.equal(code.includes('asStringValue'), nodeMajor >= 25)
16
+ // whatever the runtime, output is the same as the default mechanism
17
+ t.assert.equal(build(schema, { objectMechanism: 'native' })({ a: 'x', n: 1, extra: true }), build(schema)({ a: 'x', n: 1, extra: true }))
18
+ })
@@ -0,0 +1,301 @@
1
+ 'use strict'
2
+
3
+ // The projection is only enabled on Node 25+ (V8 13.8+). Force it here so the
4
+ // semantics are tested on every runtime in CI; the gate itself is tested in
5
+ // object-mechanism-gate.test.js.
6
+ const { test } = require('node:test')
7
+ const fjs = require('..')
8
+ const build = (schema, options) => fjs(schema, options && options.objectMechanism === 'native' ? { ...options, forceProjection: true } : options)
9
+
10
+ // objectMechanism: 'native' must be byte-identical to the default codegen on
11
+ // every projectable schema (same output, or same error), and must fall back to
12
+ // the default codegen on every schema it does not cover.
13
+
14
+ const userSchema = {
15
+ type: 'object',
16
+ required: ['id'],
17
+ properties: {
18
+ id: { type: 'integer' },
19
+ name: { type: 'string' },
20
+ email: { type: 'string', default: 'none@example.com' },
21
+ active: { type: 'boolean' },
22
+ score: { type: 'number' },
23
+ createdAt: { type: 'string', format: 'date-time' },
24
+ birthday: { type: 'string', format: 'date' },
25
+ at: { type: 'string', format: 'time' },
26
+ tags: { type: 'array', items: { type: 'string' } },
27
+ scores: { type: 'array', items: { type: 'number' } },
28
+ address: {
29
+ type: 'object',
30
+ required: ['city'],
31
+ properties: { street: { type: 'string' }, city: { type: 'string' }, zip: { type: 'string' } }
32
+ },
33
+ friends: {
34
+ type: 'array',
35
+ items: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } }
36
+ }
37
+ }
38
+ }
39
+
40
+ class User { constructor (o) { Object.assign(this, o) } }
41
+
42
+ const base = {
43
+ id: 1,
44
+ name: 'Mario "Il" Rossi\n\u0000',
45
+ email: 'm@x.it',
46
+ active: true,
47
+ score: 42.5,
48
+ createdAt: new Date(0),
49
+ birthday: new Date(86400000),
50
+ at: new Date(3600000),
51
+ tags: ['a', 'b"c', 'd'],
52
+ scores: [1, 2.5, -3],
53
+ address: { street: 'Via Roma 1', city: 'Milano', zip: '20100' },
54
+ friends: [{ id: 2, name: 'Luigi' }, { id: 3, name: 'Peach' }]
55
+ }
56
+
57
+ const inputs = {
58
+ base,
59
+ 'extra properties are dropped': { ...base, secret: 'x', internal: { a: 1 } },
60
+ 'class instance': new User(base),
61
+ 'undefined optional is omitted': { ...base, name: undefined, address: undefined, friends: undefined },
62
+ 'missing optional, default applied': { id: 1 },
63
+ nulls: { id: 1, name: null, email: null, active: null, score: null, createdAt: null, birthday: null, at: null, tags: null, scores: null, address: null, friends: null },
64
+ 'null inside arrays': { id: 1, tags: ['a', null, 'c'], scores: [1, null, 3], friends: [null, { id: 1 }] },
65
+ coercions: { id: 1.7, name: 123, active: 'yes', score: '3.5', tags: [1, 2], scores: ['4', '5'] },
66
+ 'Date in a plain string': { id: 1, name: new Date(0), tags: [new Date(0)] },
67
+ 'RegExp in a plain string': { id: 1, name: /a"b/, tags: [/c/g] },
68
+ 'date-time given as string': { id: 1, createdAt: '2020-01-01T00:00:00.000Z' },
69
+ 'toJSON on root': { id: 1, toJSON () { return { id: 7, name: 'json' } } },
70
+ 'toJSON on nested object': { id: 1, address: { toJSON () { return { city: 'Roma' } } } },
71
+ 'toJSON on array item': { id: 1, friends: [{ toJSON () { return { id: 9 } } }] },
72
+ 'required missing on root throws': { name: 'no id' },
73
+ 'required missing on nested throws': { id: 1, address: { street: 'x' } },
74
+ 'NaN throws': { id: 1, score: NaN },
75
+ 'Infinity becomes null': { id: 1, score: Infinity },
76
+ 'bigint integer': { id: 10n },
77
+ 'object items with missing fields': { id: 1, friends: [{ id: 1 }, { name: 'x' }, {}] },
78
+ 'empty arrays': { id: 1, tags: [], scores: [], friends: [] },
79
+ 'input key order differs from schema': { friends: [], address: { zip: '1', city: 'c', street: 's' }, score: 1, id: 1 },
80
+ 'null root': null,
81
+ 'non-array where array expected throws': { id: 1, tags: 'abc' }
82
+ }
83
+
84
+ const run = (fn, v) => {
85
+ try { return { ok: fn(v) } } catch (e) { return { err: `${e.constructor.name}: ${e.message}` } }
86
+ }
87
+
88
+ test('native mechanism is byte-identical to the default codegen', t => {
89
+ const def = build(userSchema)
90
+ const nat = build(userSchema, { objectMechanism: 'native' })
91
+ t.plan(Object.keys(inputs).length)
92
+ for (const [name, input] of Object.entries(inputs)) {
93
+ t.assert.deepStrictEqual(run(nat, input), run(def, input), name)
94
+ }
95
+ })
96
+
97
+ test('native mechanism: array root of objects', t => {
98
+ const schema = { type: 'array', items: { type: 'object', required: ['id'], properties: { id: { type: 'integer' }, name: { type: 'string' } } } }
99
+ const def = build(schema)
100
+ const nat = build(schema, { objectMechanism: 'native' })
101
+ const data = [
102
+ [],
103
+ [{ id: 1, name: 'a' }, { id: 2 }, { id: 3, name: null, extra: true }],
104
+ Array.from({ length: 50 }, (_, i) => ({ id: i, name: 'n' + i })),
105
+ [{ name: 'missing id' }],
106
+ null,
107
+ 'not an array'
108
+ ]
109
+ t.plan(data.length + 1)
110
+ for (const d of data) t.assert.deepStrictEqual(run(nat, d), run(def, d))
111
+ // pool grows and shrinks with the input length without leaking previous items
112
+ t.assert.equal(nat([{ id: 1 }]), '[{"id":1}]')
113
+ })
114
+
115
+ test('native mechanism: array root of primitives', t => {
116
+ t.plan(3)
117
+ for (const items of [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }]) {
118
+ const schema = { type: 'array', items }
119
+ const d = ['a', 1, null, true, 2.5, 'b']
120
+ t.assert.deepStrictEqual(run(build(schema, { objectMechanism: 'native' }), d), run(build(schema), d))
121
+ }
122
+ })
123
+
124
+ test('native mechanism generates projection code only for projectable schemas', t => {
125
+ const projectable = [
126
+ userSchema,
127
+ { type: 'object', properties: { a: { type: 'string' } } },
128
+ { type: 'array', items: { type: 'object', properties: { a: { type: 'string' } } } },
129
+ { type: 'object', properties: { a: { type: 'array', items: { type: 'array', items: { type: 'string' } } } } },
130
+ { type: 'object', properties: { a: { type: 'array', items: { type: 'object', properties: { b: { type: 'object', properties: { c: { type: 'string' } } } } } } } },
131
+ { type: 'object', properties: { a: { type: ['string', 'null'] } } }
132
+ ]
133
+ const notProjectable = [
134
+ { type: 'string' },
135
+ { type: 'object', properties: {} },
136
+ { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: true },
137
+ { type: 'object', properties: { a: { type: 'string' } }, patternProperties: { '^x': { type: 'string' } } },
138
+ { type: 'object', nullable: true, properties: { a: { type: 'string' } } },
139
+ { type: 'object', properties: { a: { type: 'string', nullable: true } } },
140
+ { type: 'object', properties: { a: { type: ['string', 'number'] } } },
141
+ { type: 'object', properties: { a: { type: ['integer', 'null'] } } },
142
+ { type: 'object', properties: { a: { const: 'x' } } },
143
+ { type: 'object', properties: { a: { type: 'string', enum: ['x'] } } },
144
+ { type: 'object', properties: { a: { anyOf: [{ type: 'string' }] } } },
145
+ { type: 'object', properties: { a: { type: 'string', format: 'email' } } },
146
+ { type: 'object', properties: { a: { $ref: '#/definitions/s' } }, definitions: { s: { type: 'string' } } },
147
+ { type: 'object', properties: { a: { type: 'array', items: [{ type: 'string' }] } } },
148
+ { type: 'object', properties: { a: { type: 'array' } } },
149
+ { type: 'object', properties: { a: { type: 'null' } } }
150
+ ]
151
+ t.plan(projectable.length + notProjectable.length)
152
+ for (const s of projectable) {
153
+ t.assert.ok(build(s, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), JSON.stringify(s))
154
+ }
155
+ for (const s of notProjectable) {
156
+ t.assert.ok(!build(s, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), JSON.stringify(s))
157
+ }
158
+ })
159
+
160
+ test('native mechanism: unsupported schemas keep the default output', t => {
161
+ const schema = {
162
+ type: 'object',
163
+ properties: { a: { type: 'string' }, b: { type: 'string', nullable: true } },
164
+ additionalProperties: { type: 'number' }
165
+ }
166
+ const data = { a: 'x', b: null, c: 1, d: 2 }
167
+ t.plan(1)
168
+ t.assert.equal(build(schema, { objectMechanism: 'native' })(data), build(schema)(data))
169
+ })
170
+
171
+ test('objectMechanism option validation', t => {
172
+ t.plan(3)
173
+ t.assert.doesNotThrow(() => build({ type: 'object', properties: { a: { type: 'string' } } }, { objectMechanism: 'default' }))
174
+ t.assert.doesNotThrow(() => build({ type: 'object', properties: { a: { type: 'string' } } }, { objectMechanism: 'native' }))
175
+ t.assert.throws(() => build({ type: 'object', properties: { a: { type: 'string' } } }, { objectMechanism: 'turbo' }), /Unsupported object mechanism turbo/)
176
+ })
177
+
178
+ test('native mechanism: scratch does not leak state between calls', t => {
179
+ const schema = { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' }, nested: { type: 'object', properties: { c: { type: 'string' } } } } }
180
+ const nat = build(schema, { objectMechanism: 'native' })
181
+ t.plan(3)
182
+ t.assert.equal(nat({ a: '1', b: '2', nested: { c: '3' } }), '{"a":"1","b":"2","nested":{"c":"3"}}')
183
+ t.assert.equal(nat({ a: '1' }), '{"a":"1"}')
184
+ t.assert.equal(nat({ nested: {} }), '{"nested":{}}')
185
+ })
186
+
187
+ test('native mechanism works in standalone mode', t => {
188
+ const schema = { type: 'object', properties: { a: { type: 'string' }, n: { type: 'number' } } }
189
+ const code = build(schema, { objectMechanism: 'native', mode: 'standalone' })
190
+ const fs = require('node:fs'); const path = require('node:path')
191
+ // written under test/fixtures like the other standalone tests, so that the
192
+ // generated require('fast-json-stringify/...') resolves against this package
193
+ const file = path.resolve('test/fixtures', 'standalone-native.js')
194
+ fs.writeFileSync(file, code)
195
+ t.after(() => fs.rmSync(file, { force: true }))
196
+ const stringify = require(file)
197
+ t.plan(2)
198
+ t.assert.ok(code.includes('asStringValue'), 'projection code is emitted')
199
+ t.assert.equal(stringify({ a: 'x', n: 1.5, extra: true }), '{"a":"x","n":1.5}')
200
+ })
201
+
202
+ test('native mechanism: required keys not declared in properties still throw', t => {
203
+ const schema = { type: 'object', required: ['id', 'token'], properties: { id: { type: 'integer' } } }
204
+ const def = build(schema)
205
+ const nat = build(schema, { objectMechanism: 'native' })
206
+ t.plan(2)
207
+ t.assert.deepStrictEqual(run(nat, { id: 1 }), run(def, { id: 1 }))
208
+ t.assert.deepStrictEqual(run(nat, { id: 1, token: 'x' }), run(def, { id: 1, token: 'x' }))
209
+ })
210
+
211
+ test('native mechanism: deeply nested schema beyond the projection depth falls back', t => {
212
+ let schema = { type: 'string' }
213
+ for (let i = 0; i < 40; i++) schema = { type: 'object', properties: { p: schema } }
214
+ t.plan(1)
215
+ t.assert.ok(!build(schema, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'))
216
+ })
217
+
218
+ test('native mechanism: [primitive, null] multi-type properties keep the strict dispatch', t => {
219
+ const schema = {
220
+ type: 'object',
221
+ properties: {
222
+ s: { type: ['string', 'null'] },
223
+ sr: { type: ['null', 'string'] },
224
+ sd: { type: ['string', 'null'], format: 'date-time' },
225
+ sdef: { type: ['string', 'null'], default: 'd' },
226
+ n: { type: ['number', 'null'] },
227
+ b: { type: ['boolean', 'null'] },
228
+ arr: { type: 'array', items: { type: ['string', 'null'] } }
229
+ }
230
+ }
231
+ const def = build(schema)
232
+ const nat = build(schema, { objectMechanism: 'native' })
233
+ const cases = [
234
+ { s: 'x', sr: 'y', sd: new Date(0), sdef: 'z', n: 1.5, b: true, arr: ['a', null] },
235
+ { s: null, sr: null, sd: null, sdef: null, n: null, b: null, arr: [null] },
236
+ {},
237
+ { s: new Date(0) },
238
+ { s: /re/ },
239
+ { s: { toString () { return 'custom' } } },
240
+ { s: {} },
241
+ { s: 5 },
242
+ { s: true },
243
+ { sd: '2020-01-01' },
244
+ { sd: 5 },
245
+ { n: Infinity },
246
+ { n: NaN },
247
+ { n: '3' },
248
+ { n: 10n },
249
+ { b: 'y' },
250
+ { b: 0 },
251
+ { arr: ['a', 5] },
252
+ { arr: [new Date(0)] }
253
+ ]
254
+ t.plan(cases.length + 2)
255
+ t.assert.ok(build(schema, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), 'schema is projected')
256
+ for (const c of cases) t.assert.deepStrictEqual(run(nat, c), run(def, c), JSON.stringify(c, (k, v) => typeof v === 'bigint' ? 'bigint' : v))
257
+ // ['integer', 'null'] and non-null multi-types are not projected
258
+ const notProjected = [{ type: ['integer', 'null'] }, { type: ['string', 'number'] }, { type: ['string', 'null', 'number'] }, { type: ['string', 'null'], format: 'email' }]
259
+ t.assert.ok(notProjected.every((p) => !build({ type: 'object', properties: { a: p } }, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue')))
260
+ })
261
+
262
+ test('native mechanism projects the realistic api-response fixtures byte for byte', t => {
263
+ const fixtures = require('../benchmark/fixtures/api-responses')
264
+ t.plan(fixtures.length * 2)
265
+ for (const { name, schema, input } of fixtures) {
266
+ const def = build(schema)
267
+ const nat = build(schema, { objectMechanism: 'native' })
268
+ t.assert.ok(build(schema, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), `${name} is projected`)
269
+ t.assert.equal(nat(input), def(input), name)
270
+ }
271
+ })
272
+
273
+ test('native mechanism: array elements never share scratch objects (no aliasing)', t => {
274
+ // items[].product.images[] : an array of objects inside an array of objects.
275
+ // Every element must get its own scratch tree, otherwise the last element
276
+ // written wins for all of them.
277
+ const schema = {
278
+ type: 'array',
279
+ items: {
280
+ type: 'object',
281
+ properties: {
282
+ id: { type: 'integer' },
283
+ images: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } } } },
284
+ meta: { type: 'object', properties: { k: { type: 'string' } } }
285
+ }
286
+ }
287
+ }
288
+ const def = build(schema)
289
+ const nat = build(schema, { objectMechanism: 'native' })
290
+ const a = [
291
+ { id: 1, images: [{ url: 'a', tags: ['x'] }, { url: 'b', tags: [] }], meta: { k: '1' } },
292
+ { id: 2, images: [{ url: 'c', tags: ['y', 'z'] }], meta: { k: '2' } },
293
+ { id: 3, images: [], meta: null }
294
+ ]
295
+ const b = [{ id: 9, images: [{ url: 'q', tags: ['k'] }] }]
296
+ t.plan(4)
297
+ t.assert.equal(nat(a), def(a))
298
+ t.assert.equal(nat(b), def(b))
299
+ t.assert.equal(nat(a), def(a), 'shrinking then growing again keeps elements independent')
300
+ t.assert.ok(nat(a).includes('"url":"a"') && nat(a).includes('"url":"c"'))
301
+ })
package/types/index.d.ts CHANGED
@@ -193,6 +193,15 @@ declare namespace build {
193
193
  * @default 'default'
194
194
  */
195
195
  largeArrayMechanism?: 'default' | 'json-stringify'
196
+ /**
197
+ * Specify how objects should be stringified. `'native'` projects the
198
+ * input into schema-shaped scratch objects and delegates to the native
199
+ * `JSON.stringify`. Effective on Node.js 25+ only (no-op on older
200
+ * runtimes); schemas it cannot project silently use the default mechanism.
201
+ *
202
+ * @default 'default'
203
+ */
204
+ objectMechanism?: 'default' | 'native'
196
205
  /**
197
206
  * Eagerly compile the Ajv validators used by `anyOf`, `oneOf` and
198
207
  * `if/then/else` at build time instead of on the first serialization
@@ -255,6 +255,10 @@ expect(stringify7).type.not.toBeCallableWith('a string')
255
255
  // largeArrayMechanism
256
256
  build({}, { largeArrayMechanism: 'json-stringify' })
257
257
  build({}, { largeArrayMechanism: 'default' })
258
+
259
+ // objectMechanism
260
+ build({}, { objectMechanism: 'native' })
261
+ build({}, { objectMechanism: 'default' })
258
262
  expect(build).type.not.toBeCallableWith({} as Schema, { largeArrayMechanism: 'invalid' })
259
263
 
260
264
  build({}, { largeArraySize: 2000 })