From ff8d519254679ba5150d7d00f6b28d92dc1a296d Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sat, 10 Oct 2026 11:16:31 +0000 Subject: [PATCH 1/2] fix: coerce undefined values like null when serializing Values set to undefined previously crashed the generated serializer with a TypeError (e.g. Cannot read properties of undefined (reading 'toString')) for string and object types, or threw a conversion error for integer, number and date-time formats. Every position that already coerces null to the falsy value of the declared type now treats undefined identically: "" for strings and the date/date-time/time formats, 0 for integer and number, {} for object, [] for array, false for boolean (unchanged), and null for nullable and null-typed positions. Object properties set to undefined are still skipped. --- index.js | 28 +++--- lib/serializer.js | 12 ++- test/missing-values.test.js | 195 ++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 17 deletions(-) diff --git a/index.js b/index.js index 63372eaa..bb9ed8c4 100644 --- a/index.js +++ b/index.js @@ -818,7 +818,7 @@ function buildObject (context, location, input) { // ${asComment(schemaRef)} function ${functionName} (input) { const obj = ${toJSON('input')} - if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} + if (obj == null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} let json = '' ${buildInnerObject(context, location, 'obj')} @@ -834,7 +834,7 @@ function buildObject (context, location, input) { const objVar = `obj_${context.uid++}` const code = ` const ${objVar} = ${toJSON(input)} - if (${objVar} === null) { + if (${objVar} == null) { json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} } else { ${buildInnerObject(context, location, objVar)} @@ -880,7 +880,7 @@ function buildArray (context, location, input) { ` functionCode += ` - if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} + if (obj == null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} if (!Array.isArray(obj)) { throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)}) } @@ -964,7 +964,7 @@ function buildArray (context, location, input) { const objVar = `obj_${context.uid++}` let inlinedCode = ` const ${objVar} = ${input} - if (${objVar} === null) { + if (${objVar} == null) { json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} } else if (!Array.isArray(${objVar})) { throw new TypeError(${JSON.stringify(`The value of '${safeSchemaRef}' does not match schema definition.`)}) @@ -1051,11 +1051,11 @@ function buildArrayTypeCondition (type, accessor) { let condition switch (type) { case 'null': - condition = `${accessor} === null` + condition = `${accessor} == null` break case 'string': condition = `typeof ${accessor} === 'string' || - ${accessor} === null || + ${accessor} == null || ${accessor} instanceof Date || ${accessor} instanceof RegExp || ( @@ -1108,7 +1108,7 @@ function buildMultiTypeSerializer (context, location, input) { switch (type) { case 'null': code += ` - ${statement} (${input} === null) { + ${statement} (${input} == null) { ${nestedResult} } ` @@ -1117,7 +1117,7 @@ function buildMultiTypeSerializer (context, location, input) { code += ` ${statement}( typeof ${input} === "string" || - ${input} === null || + ${input} == null || ${input} instanceof Date || ${input} instanceof RegExp || ( @@ -1141,7 +1141,7 @@ function buildMultiTypeSerializer (context, location, input) { } case 'integer': { code += ` - ${statement}(Number.isInteger(${input}) || ${input} === null) { + ${statement}(Number.isInteger(${input}) || ${input} == null) { ${nestedResult} } ` @@ -1152,7 +1152,7 @@ function buildMultiTypeSerializer (context, location, input) { // by this branch and serialized as an object (dropping its items). Exclude // arrays here so a sibling `array` type in the same `type` list can match. code += ` - ${statement}((typeof ${input} === "object" && !Array.isArray(${input})) || ${input} === null) { + ${statement}((typeof ${input} === "object" && !Array.isArray(${input})) || ${input} == null) { ${nestedResult} } ` @@ -1160,7 +1160,7 @@ function buildMultiTypeSerializer (context, location, input) { } default: { code += ` - ${statement}(typeof ${input} === "${type}" || ${input} === null) { + ${statement}(typeof ${input} === "${type}" || ${input} == null) { ${nestedResult} } ` @@ -1193,7 +1193,7 @@ function buildSingleTypeSerializer (context, location, input) { } else { return ` if (typeof ${input} !== 'string') { - if (${input} === null) { + if (${input} == null) { json += JSON_STR_EMPTY_STRING } else if (${input} instanceof Date) { json += JSON_STR_QUOTE + ${input}.toISOString() + JSON_STR_QUOTE @@ -1328,7 +1328,7 @@ function buildConstSerializer (location, input) { if (hasNullType) { code += ` - if (${input} === null) { + if (${input} == null) { json += JSON_STR_NULL } else { ` @@ -1538,7 +1538,7 @@ function buildValue (context, location, input) { const nullable = schema.nullable === true if (nullable) { code += ` - if (${input} === null) { + if (${input} == null) { json += JSON_STR_NULL } else { ` diff --git a/lib/serializer.js b/lib/serializer.js index 1b3a77d8..85e5de23 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -66,6 +66,9 @@ module.exports = class Serializer { } else if (typeof i === 'bigint') { return i.toString() } + if (i == null) { + return '0' + } /* eslint no-undef: "off" */ const integer = this.parseInteger(i) // check if number is Infinity or NaN @@ -77,6 +80,9 @@ module.exports = class Serializer { } asNumber (i) { + if (i == null) { + return '0' + } // fast cast to number const num = Number(i) // check if number is NaN @@ -95,7 +101,7 @@ module.exports = class Serializer { } asDateTime (date) { - if (date === null) return '""' + if (date == null) return '""' if (date instanceof Date) { return '"' + date.toISOString() + '"' } @@ -106,7 +112,7 @@ module.exports = class Serializer { } asDate (date) { - if (date === null) return '""' + if (date == null) return '""' if (date instanceof Date) { return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' } @@ -117,7 +123,7 @@ module.exports = class Serializer { } asTime (date) { - if (date === null) return '""' + if (date == null) return '""' if (date instanceof Date) { return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' } diff --git a/test/missing-values.test.js b/test/missing-values.test.js index 5ddf4771..a79d3276 100644 --- a/test/missing-values.test.js +++ b/test/missing-values.test.js @@ -86,3 +86,198 @@ test('handle null when value should be boolean', (t) => { t.assert.equal('{"bool":false}', stringify({ bool: null })) }) + +test('handle undefined when value should be string', (t) => { + t.plan(1) + + const stringify = build({ + type: 'string' + }) + + t.assert.equal('""', stringify(undefined)) +}) + +test('handle undefined in array of strings', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'string' + } + }) + + t.assert.equal('["test1","test2","test3",""]', stringify(['test1', 'test2', 'test3', undefined])) +}) + +test('handle undefined in array of integers', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'integer' + } + }) + + t.assert.equal('[1,2,0]', stringify([1, 2, undefined])) +}) + +test('handle undefined in array of numbers', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'number' + } + }) + + t.assert.equal('[1.5,0]', stringify([1.5, undefined])) +}) + +test('handle undefined in array of booleans', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'boolean' + } + }) + + t.assert.equal('[true,false]', stringify([true, undefined])) +}) + +test('handle undefined in array of objects', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + }) + + t.assert.equal('[{"str":"test"},{}]', stringify([{ str: 'test' }, undefined])) +}) + +test('handle undefined in array of date-time strings', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'string', + format: 'date-time' + } + }) + + t.assert.equal('["2020-01-01T00:00:00.000Z",""]', stringify(['2020-01-01T00:00:00.000Z', undefined])) +}) + +test('handle undefined in array of date strings', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'string', + format: 'date' + } + }) + + t.assert.equal('["2020-01-01",""]', stringify(['2020-01-01', undefined])) +}) + +test('handle undefined in array of time strings', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'string', + format: 'time' + } + }) + + t.assert.equal('["00:00:00",""]', stringify(['00:00:00', undefined])) +}) + +test('handle undefined when value is nullable', (t) => { + t.plan(1) + + const stringify = build({ + type: 'string', + nullable: true + }) + + t.assert.equal('null', stringify(undefined)) +}) + +test('handle undefined in tuple of strings', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: [ + { + type: 'string' + }, + { + type: 'string' + } + ] + }) + + t.assert.equal('["test",""]', stringify(['test', undefined])) +}) + +test('handle undefined in array with multiple item types', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: ['string', 'integer'] + } + }) + + t.assert.equal('["test",""]', stringify(['test', undefined])) +}) + +test('handle undefined in nested arrays', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'array', + items: { + type: 'string' + } + } + }) + + t.assert.equal('[["test"],[]]', stringify([['test'], undefined])) +}) + +test('undefined object properties are still skipped', (t) => { + t.plan(1) + + const stringify = build({ + type: 'object', + properties: { + str: { + type: 'string' + } + } + }) + + t.assert.equal('{}', stringify({ str: undefined })) +}) From 63c9bc0bcd74ec04e9af1c5ef8b51090d38caf20 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sat, 10 Oct 2026 12:28:45 +0100 Subject: [PATCH 2/2] test: cover undefined in multi-type, tuple and const positions --- test/missing-values.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/missing-values.test.js b/test/missing-values.test.js index a79d3276..27a7c6d1 100644 --- a/test/missing-values.test.js +++ b/test/missing-values.test.js @@ -281,3 +281,14 @@ test('undefined object properties are still skipped', (t) => { t.assert.equal('{}', stringify({ str: undefined })) }) + +test('handle undefined in multi-type, tuple and const positions', (t) => { + t.plan(6) + + t.assert.equal('["a",null]', build({ type: 'array', items: { type: ['null', 'string'] } })(['a', undefined])) + t.assert.equal('[1,0]', build({ type: 'array', items: { type: ['integer', 'string'] } })([1, undefined])) + t.assert.equal('[{},{}]', build({ type: 'array', items: { type: ['object', 'string'] } })([{}, undefined])) + t.assert.equal('[true,false]', build({ type: 'array', items: { type: ['boolean', 'string'] } })([true, undefined])) + t.assert.equal('[null]', build({ type: 'array', items: [{ type: 'null' }] })([undefined])) + t.assert.equal('["x",null]', build({ type: 'array', items: { type: ['string', 'null'], const: 'x' } })(['x', undefined])) +})