Semantic Diff

#9015 Match untagged variants with a literal switch and one typeof dispatch

8 commits

12df5b73c60bef342965980b3591b28823a3ab23..pr-9015

5e9bfb668 · Christoph Knittel + Claude · Oct 24, 2026, 13:27 UTC

Say in rule 6 that variants with some @unboxed constructors are checked

#9021 checks a variant with some @unboxed constructors like an untagged one: tagged blocks count as one object case, and an untagged case's @as is not a tag. Rule 6 now says so and names its tests.

0ddbc124e · Christoph Knittel + Claude · Oct 24, 2026, 16:18 UTC

Require a private abbreviation for every subclass

Why

A public abbreviation could declare a subclass when its arguments were not all type variables, meant for phantom type hierarchies. The test was syntactic, so @instanceOf("Dog") type dog = animal<int> passed, and every animal<int> was then a dog without a coercion.

A subclass is always a private abbreviation, phantom hierarchies included; upcasts use :>.

addedDiagnostic public subclass of an instantiated class

@instanceOf("Animal") type animal<'a>@instanceOf("Dog") type dog = animal<int>
diagnostic
Before
After
noneA subclass must be a private abbreviation: write `= private ...` so that instances of Animal cannot be used as instances of Dog without a coercion.
80bb28fe3 · cknitt + Claude · Oct 24, 2026, 10:17 UTC

Match untagged variants with a literal switch and one typeof dispatch

Why

The payload dispatch of an untagged variant ran its instance checks before the typeof checks, so strings and numbers reached instanceof: a class missing at run time threw for them, and a Symbol.hasInstance could claim them. Literals were tested by a size heuristic that chose between two equivalent expressions, the object case by a guard per instance case, and a subclass by negating it in its superclass's check.

The literals come first, compared with === by one switch (x) (an if per body for at most two), whose default is the payload dispatch: one switch (typeof x) over the primitive kinds, whose default is an if chain over the instance cases (a subclass first), the function case, the tagged constructors and the object case. In a complete match the last arm is not checked. A literal without an arm skips the switch when no payload check would accept it, and a null that only the object check would accept adds x !== null to it. Checks leading to the default before the last checked arm fold into its check, so class checks run only on objects. Dynamic_checks.is_a_literal_case and the per-instance guards are gone.

modifiedJS primitives before classes

@unboxedtype t = Missing(missing) | Text(string) | Count(float)let kind = value =>  switch value {  | Missing(_) => "missing"  | Text(text) => text  | Count(_) => "count"  }
js
Before
After
if (value instanceof UntaggedMissingClass) { return "missing";} switch (typeof value) {switch (typeof value) { case "string" : case "string" : return value; return value; case "number" : case "number" : return "count"; return "count"; default: return "missing";}}

modifiedJS instances without an arm

@unboxedtype t = D(Date.t) | R(RegExp.t) | O({x: int}) | S(string) | I(int) | F(int => int)let kind = value =>  switch value {  | O(_) => "O"  | S(_) => "S"  | _ => "other"  }
js
Before
After
if (value instanceof RegExp || value instanceof Date) { if (typeof value === "string") { return "S";} else if (typeof value === "object" && !(value instanceof RegExp) && !(value instanceof Date)) { return "O";} else { return "other"; return "other";}}switch (typeof value) { case "object" : return "O"; case "string" : return "S"; default: return "other";}

modifiedJS a subclass first

@unboxedtype upload = Blob(Blob.t) | File(File.t)let kind = upload =>  switch upload {  | Blob(_) => "blob"  | File(_) => "file"  }
js
Before
After
if (upload instanceof Blob && !(upload instanceof File)) { return "blob";} else {if (upload instanceof File) { return "file"; return "file"; } else { return "blob";}}

Review

  • Does break in an arm leave the generated switch instead of the loop? No: every arm placed in a generated switch is compiled in a switch context, where break and continue are labeled; untagged_loop_control_test.res checks arms of every part.
  • What changes for a value outside every case? It lands in the last arm of the chain: with PPromise | PFloat the float case is now checked, so a thenable lands in PPromise (rule 10).
11ffe4e8b · cknitt + Claude · Oct 24, 2026, 10:25 UTC

Drop literal arms that a complete payload dispatch runs anyway

A complete payload dispatch with one body runs it unchecked, so a literal arm with the same body needs no case of its own.

modifiedJS literal arm equal to the payload arm

@unboxedtype rec t = | @as(null) Null | @as(undefined) Undefined | WhyNotAnotherOne | Point({x: int, y: int})let plus = (x, y) =>  switch y {  | Null | Undefined => x  | WhyNotAnotherOne => WhyNotAnotherOne  | Point(_) => WhyNotAnotherOne  }
js
Before
After
switch (y) { case null : case undefined : return x; case "WhyNotAnotherOne" : return "WhyNotAnotherOne"; default: return "WhyNotAnotherOne";if (y == null) { return x;} else { return "WhyNotAnotherOne"; }}
c54946035 · cknitt + Claude · Oct 24, 2026, 10:37 UTC

Update the gentype example for the untagged dispatch

The gentype example matches an untagged variant too; its output follows the new dispatch.

5af3b887a · cknitt + Claude · Oct 24, 2026, 16:18 UTC

Say precisely when literals use an if and what folds

Rule 7 counts the literals that get a case, as the code does, and rule 11 names the values constant folding sees: literals and blocks of constants.

9b1fff838 · cknitt + Claude · Oct 24, 2026, 16:21 UTC

Emit untagged checks directly instead of through Dynamic_checks

The check AST in Ast_untagged_variants only encoded the int-tag test of tagged variants and the instance checks. Js_exp_make now builds both directly (is_int_tag from the literal list, is_instance_of), and matching_facts no longer carries has_null, has_undefined and has_other_literal, which that test derived. A class is never a switch key, so block_type_name no longer pretends to emit one. No output changes.

66880b8db · cknitt + Claude · Oct 24, 2026, 16:23 UTC

Share which literal passes which payload check

Code generation and constant folding each had a table of the literals a payload case accepts, and they disagreed on null and an object case. Variant_runtime.literal_passes is now the one table; folding follows the generated code, where typeof null is "object", so null without a null case folds into the object case as it is matched at run time.