From 61b6f100fae42e8df37c8309bd520cbf6b7810d5 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 25 Feb 2026 09:02:19 +0100 Subject: [PATCH] Fix null falling into object branch in untagged variant switch (#8251) When pattern matching on untagged variants with both an Object case and a null/wildcard case, null (typeof "object") would incorrectly match the object branch. Emit a null check before the typeof switch when both conditions are present. Fixes #8251 Signed-Off-By: Cristiano Calcagno Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + compiler/core/lam_compile.ml | 16 ++++++++++- tests/tests/src/js_json_test.mjs | 46 +++++++++++++++++++++++--------- tests/tests/src/js_json_test.res | 15 +++++++++++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 716caec297a..eb04248cf2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ #### :bug: Bug fix - Fix compiler crash (`Fatal error: Parmatch.all_record_args`) when matching empty dict/record patterns. https://github.com/rescript-lang/rescript/pull/8246 +- Fix `null` falling into the object branch instead of the wildcard when pattern matching on untagged variants with both `Object` and `null` cases. https://github.com/rescript-lang/rescript/pull/8253 #### :memo: Documentation diff --git a/compiler/core/lam_compile.ml b/compiler/core/lam_compile.ml index 739056132b1..680bce50a7d 100644 --- a/compiler/core/lam_compile.ml +++ b/compiler/core/lam_compile.ml @@ -814,6 +814,10 @@ let compile output_prefix = | _ -> false in let clause_is_not_typeof (tag, _) = tag_is_not_typeof tag in + let clause_is_object_typeof = function + | Ast_untagged_variants.Untagged ObjectType, _ -> true + | _ -> false + in let switch ?default ?declaration e clauses = let not_typeof_clauses, typeof_clauses = List.partition clause_is_not_typeof clauses @@ -827,7 +831,17 @@ let compile output_prefix = (E.emit_check (IsInstanceOf (instance_type, Expr e))) switch_body ~else_:[build_if_chain rest] - | _ -> S.string_switch ?default ?declaration (E.typeof e) typeof_clauses + | _ -> + let typeof_switch () = + S.string_switch ?default ?declaration (E.typeof e) typeof_clauses + in + if has_null_case && List.exists clause_is_object_typeof typeof_clauses + then + match default with + | Some default_body -> + S.if_ (E.is_null e) default_body ~else_:[typeof_switch ()] + | None -> typeof_switch () + else typeof_switch () in build_if_chain not_typeof_clauses in diff --git a/tests/tests/src/js_json_test.mjs b/tests/tests/src/js_json_test.mjs index 9c86f04e337..c69a9fd6f33 100644 --- a/tests/tests/src/js_json_test.mjs +++ b/tests/tests/src/js_json_test.mjs @@ -367,24 +367,44 @@ Mocha.describe("Js_json_test", () => { Test_utils.eq("File \"js_json_test.res\", line 314, characters 7-14", Js_json.decodeArray({}), undefined); Test_utils.eq("File \"js_json_test.res\", line 315, characters 7-14", Js_json.decodeArray(1.23), undefined); }); + Mocha.test("JSON Array/Object switch falls through to wildcard on null", () => { + let classifyArrayOrObject = json => { + if (Array.isArray(json)) { + return json.length; + } + if (json === null) { + return; + } + switch (typeof json) { + case "object" : + Js_dict.get(json, "x"); + return 0; + default: + return; + } + }; + Test_utils.eq("File \"js_json_test.res\", line 328, characters 7-14", classifyArrayOrObject(null), undefined); + Test_utils.eq("File \"js_json_test.res\", line 329, characters 7-14", classifyArrayOrObject([1]), 1); + Test_utils.eq("File \"js_json_test.res\", line 330, characters 7-14", classifyArrayOrObject({}), 0); + }); Mocha.test("JSON decodeBoolean", () => { - Test_utils.eq("File \"js_json_test.res\", line 319, characters 7-14", Js_json.decodeBoolean("test"), undefined); - Test_utils.eq("File \"js_json_test.res\", line 320, characters 7-14", Js_json.decodeBoolean(true), true); - Test_utils.eq("File \"js_json_test.res\", line 321, characters 7-14", Js_json.decodeBoolean([]), undefined); - Test_utils.eq("File \"js_json_test.res\", line 322, characters 7-14", Js_json.decodeBoolean(null), undefined); - Test_utils.eq("File \"js_json_test.res\", line 323, characters 7-14", Js_json.decodeBoolean({}), undefined); - Test_utils.eq("File \"js_json_test.res\", line 324, characters 7-14", Js_json.decodeBoolean(1.23), undefined); + Test_utils.eq("File \"js_json_test.res\", line 334, characters 7-14", Js_json.decodeBoolean("test"), undefined); + Test_utils.eq("File \"js_json_test.res\", line 335, characters 7-14", Js_json.decodeBoolean(true), true); + Test_utils.eq("File \"js_json_test.res\", line 336, characters 7-14", Js_json.decodeBoolean([]), undefined); + Test_utils.eq("File \"js_json_test.res\", line 337, characters 7-14", Js_json.decodeBoolean(null), undefined); + Test_utils.eq("File \"js_json_test.res\", line 338, characters 7-14", Js_json.decodeBoolean({}), undefined); + Test_utils.eq("File \"js_json_test.res\", line 339, characters 7-14", Js_json.decodeBoolean(1.23), undefined); }); Mocha.test("JSON decodeNull", () => { - Test_utils.eq("File \"js_json_test.res\", line 328, characters 7-14", Js_json.decodeNull("test"), undefined); - Test_utils.eq("File \"js_json_test.res\", line 329, characters 7-14", Js_json.decodeNull(true), undefined); - Test_utils.eq("File \"js_json_test.res\", line 330, characters 7-14", Js_json.decodeNull([]), undefined); - Test_utils.eq("File \"js_json_test.res\", line 331, characters 7-14", Js_json.decodeNull(null), null); - Test_utils.eq("File \"js_json_test.res\", line 332, characters 7-14", Js_json.decodeNull({}), undefined); - Test_utils.eq("File \"js_json_test.res\", line 333, characters 7-14", Js_json.decodeNull(1.23), undefined); + Test_utils.eq("File \"js_json_test.res\", line 343, characters 7-14", Js_json.decodeNull("test"), undefined); + Test_utils.eq("File \"js_json_test.res\", line 344, characters 7-14", Js_json.decodeNull(true), undefined); + Test_utils.eq("File \"js_json_test.res\", line 345, characters 7-14", Js_json.decodeNull([]), undefined); + Test_utils.eq("File \"js_json_test.res\", line 346, characters 7-14", Js_json.decodeNull(null), null); + Test_utils.eq("File \"js_json_test.res\", line 347, characters 7-14", Js_json.decodeNull({}), undefined); + Test_utils.eq("File \"js_json_test.res\", line 348, characters 7-14", Js_json.decodeNull(1.23), undefined); }); Mocha.test("JSON serialize/deserialize identity", () => { - let idtest = obj => Test_utils.eq("File \"js_json_test.res\", line 339, characters 27-34", obj, Js_json.deserializeUnsafe(Js_json.serializeExn(obj))); + let idtest = obj => Test_utils.eq("File \"js_json_test.res\", line 354, characters 27-34", obj, Js_json.deserializeUnsafe(Js_json.serializeExn(obj))); idtest(undefined); idtest({ hd: [ diff --git a/tests/tests/src/js_json_test.res b/tests/tests/src/js_json_test.res index e04ef21b21c..26fa6320d0f 100644 --- a/tests/tests/src/js_json_test.res +++ b/tests/tests/src/js_json_test.res @@ -315,6 +315,21 @@ describe(__MODULE__, () => { eq(__LOC__, J.decodeArray(J.number(1.23)), None) }) + test("JSON Array/Object switch falls through to wildcard on null", () => { + let classifyArrayOrObject = (json: J.t) => + switch json { + | J.Array(items) => Some(items->Js.Array2.length) + | J.Object(dict) => + ignore(Js.Dict.get(dict, "x")) + Some(0) + | _ => None + } + + eq(__LOC__, classifyArrayOrObject(J.null), None) + eq(__LOC__, classifyArrayOrObject(J.array([J.number(1.)])), Some(1)) + eq(__LOC__, classifyArrayOrObject(J.object_(Js.Dict.empty())), Some(0)) + }) + test("JSON decodeBoolean", () => { eq(__LOC__, J.decodeBoolean(J.string("test")), None) eq(__LOC__, J.decodeBoolean(J.boolean(true)), Some(true))