Semantic Diff

#9288 Pass undefined for an optional external argument or JSX key that is None

1 commit

e941d27298f5f7dcbc9a42cce0d5d7b349d27f7d..pr-9288

c90750038 · mununki · Nov 09, 2026, 15:21 UTC

Pass undefined for an optional external argument or JSX key that is None

Why

An optional argument of an external passed as ~x=?e, where e is a call, a dict or array lookup, a conditional or anything else that cannot be read twice to test it first, is compiled to Primitive_option.valFromOption(e). valFromOption reads a property of its argument to find a nested None, so it threw a TypeError when e was None, though its doc comment says that it keeps undefined. #8380 replaced Primitive_option.toUndefined, which tested for undefined before calling valFromOption, on that premise. The same call is emitted for a JSX key, also with -bs-jsx-preserve, for the argument of an inlined function that forwards ~x? to an external, and for an optional @int argument, or @string argument with @as, which switches on it. valFromOption now returns undefined for undefined, as it does for null, which fixes all of them.

modifiedJS Primitive_option.valFromOption

js
Before
After
function valFromOption(x) {function valFromOption(x) { if (x === null || x.BS_PRIVATE_NESTED_SOME_NONE === undefined) { if ((x == null) || x.BS_PRIVATE_NESTED_SOME_NONE === undefined) { return x; return x; } } … …}}

modifiedOutput optional argument that is None

%%raw(`function greet(name) { return name === undefined ? "Hello" : "Hello " + name }`)@val external greet: (~name: string=?, unit) => string = "greet" let greetUser = (names: dict<string>, id) => greet(~name=?names->Dict.get(id), ())Console.log(greetUser(dict{"1": "Ann"}, "2"))
js
Before and After are equal
function greetUser(names, id) {  return greet(Primitive_option.valFromOption(names[id]));}
text
Before
After
TypeError: Cannot read properties of undefined (reading 'BS_PRIVATE_NESTED_SOME_NONE')Hello

Review

  • Why change the runtime and not the compiler? Every path that emits the unchecked call goes through valFromOption: Arg_optional in ocaml_to_js_eff, the JSX key in Lam_compile_primitive, inlined functions, and @int and @as arguments, which switch on its result. get_default_undefined_from_optional returns an expression, so a test in the compiler needs the argument bound to a temporary: a statement for each such argument, through hold_arg for calls and separately for JSX keys, plus temporaries for the arguments to its left (see sequence_arguments). Bringing back toUndefined would undo #8380 for one comparison. This way the generated code stays as it is, and code that 13.0.0-alpha.5 or later already compiled works once the runtime is updated.
  • Does the extra test cost the other callers anything? They pass a value known to be Some: a match on a nested option (Pval_from_option), and the branch after x !== undefined of an @obj assignment or of an optional argument that can be read twice. For them x == null replaces x === null, still one comparison; in a micro-benchmark of 2×10^8 calls the difference is within noise for objects or numbers alone and under 0.3 ns a call for mixed inputs. The runtime grows by one character, and no other compiled module of the runtime, the stdlib or the tests changes.
  • Could an argument that can be read twice now be compiled to valFromOption(x) alone, without x !== undefined ? … : undefined? It could, but that would change much output and call the runtime for None. The next commit does it only for a JS property read, which may run a getter.