From 2fc35fe36e01bdd93da8697789865db31e820a51 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:27:59 -0400 Subject: [PATCH] fix: apply the else branch when a schema has if and else but no then buildValue only routed a schema to buildIfThenElse when both `if` and `then` were present, so a schema that pairs `if` with `else` alone fell through to ordinary serialization and every property declared only under `else` was dropped from the output. The guard now also accepts `else`, and buildIfThenElse treats `then` as optional: with no `then` the true branch adds no keywords, so it serializes with the root schema. The `then`-only and `then` plus `else` paths generate byte-identical code to main. --- index.js | 30 +++++++++++++++++------------- test/if-then-else.test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/index.js b/index.js index 3a02da13..1b737eb5 100644 --- a/index.js +++ b/index.js @@ -1261,19 +1261,23 @@ function buildIfThenElse (context, location, input) { const ifSchemaRef = getValidatorSchemaRef(context, ifLocation) context.validatorSchemaRefs.add(ifSchemaRef) - const thenLocation = location.getPropertyLocation('then') - let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema) - let thenMergedLocation = null - if (thenMergedSchemaId) { - thenMergedLocation = getMergedLocation(context, thenMergedSchemaId) - } else { - thenMergedSchemaId = `__fjs_merged_${schemaIdCounter++}` - context.mergedSchemasIds.set(thenSchema, thenMergedSchemaId) + // `then` is optional: a schema may pair `if` with `else` alone. In that case + // the true branch adds no keywords, so it serializes with the root schema. + let thenMergedLocation = rootLocation + if (thenSchema !== undefined) { + const thenLocation = location.getPropertyLocation('then') + let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema) + if (thenMergedSchemaId) { + thenMergedLocation = getMergedLocation(context, thenMergedSchemaId) + } else { + thenMergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + context.mergedSchemasIds.set(thenSchema, thenMergedSchemaId) - thenMergedLocation = mergeLocations(context, thenMergedSchemaId, [ - rootLocation, - thenLocation - ]) + thenMergedLocation = mergeLocations(context, thenMergedSchemaId, [ + rootLocation, + thenLocation + ]) + } } if (!elseSchema) { @@ -1330,7 +1334,7 @@ function buildValue (context, location, input) { return buildOneOf(context, location, input) } - if (schema.if && schema.then) { + if (schema.if && (schema.then || schema.else)) { return buildIfThenElse(context, location, input) } diff --git a/test/if-then-else.test.js b/test/if-then-else.test.js index 9220a516..782a9c4f 100644 --- a/test/if-then-else.test.js +++ b/test/if-then-else.test.js @@ -650,3 +650,36 @@ test('if with a $ref that has sibling keywords is left unchanged', (t) => { const stringify = build(schema, { schema: externalSchema }) t.assert.equal(stringify({ kind: 'foo', foo: 'ignored', bar: 'fallback' }), '{"bar":"fallback"}') }) + +test('if/else without then applies the else branch', (t) => { + t.plan(4) + + const schema = { + type: 'object', + properties: { + kind: { type: 'string' } + }, + if: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['foobar'] } + } + }, + else: { + type: 'object', + properties: { + bar: { type: 'string' } + } + } + } + + const stringify = build(schema) + + const elseOutput = stringify({ kind: 'other', bar: 'value' }) + t.assert.equal(elseOutput, '{"kind":"other","bar":"value"}') + t.assert.deepStrictEqual(JSON.parse(elseOutput), { kind: 'other', bar: 'value' }) + + const thenOutput = stringify({ kind: 'foobar', bar: 'value' }) + t.assert.equal(thenOutput, '{"kind":"foobar"}') + t.assert.deepStrictEqual(JSON.parse(thenOutput), { kind: 'foobar' }) +})