HaxeFoundation / HaxeFoundation/haxe
[js] inline changes observable behavior: Int + Null<String> yields NaN when called, 1 when inlined
- Dominant language
- Haxe
- Stars
- 6.9k
- Forks
- 715
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 11
Description
# [js] `inline` changes observable behavior: `Int + Null` yields `NaN` when called, `1` when inlined
## Repro
```haxe
class Test {
static function add(a:Int, ?b:String) {
return a + b;
}
static inline function add_inline(a:Int, ?b:String) {
return a + b;
}
static function main() {
trace(1 + null); // 1
trace(add_inline(1)); // 1
trace(add(1)); // NaN
}
}
```
```
haxe -main Test -js out.js && node out.js
```
## Actual (js)
```
Test.hx:11: 1
Test.hx:12: 1
Test.hx:13: NaN
```
`add` and `add_inline` have identical bodies and identical signatures. Adding
`inline` changes the observable result from `NaN` to `1`.
## Expected
`$type` reports the function as `(a : Int, ?b : Null) -> String` on
every target, so all three lines should produce the string `"1null"`. At
minimum, `add` and `add_inline` should agree with each other.
## Cause
The js backend emits the addition verbatim and relies on JavaScript's
polymorphic `+`:
```js
Test.add = function(a,b) {
return a + b;
};
...
console.log("Test.hx:11:", 1 + null); // literal -> 1
console.log("Test.hx:12:", 1 + null); // inlined -> 1
console.log("Test.hx:13:", Test.add(1)); // b is undefined -> NaN
```
Two distinct problems:
1. **No String coercion.** The typer concludes `String`, but the js backend
emits a bare `a + b`. `1 + null` is `1` in JavaScript and `1 + undefined`
is `NaN` — neither is a String.
2. **Omitted optional is `undefined`, not `null`.** Haxe specifies an omitted
optional argument as `null`. In the generated function `b` is simply a
missing JS argument, so it is `undefined`. This is what makes the called
form (`NaN`) differ from the inlined form, where the omitted argument has
been substituted as a literal `null` at the call site (`1`).
## The python backend gets this right
```python
@staticmethod
def add(a, b = None):
return (str(a) + ("null" if b is None else b))
```
Python emits an explicit `str()` coercion plus a `None` -> `"null"`
substitution, and returns `"1null"` for both the called and inlined forms.
So the typer's `String` conclusion is achievable and correctly implemented
elsewhere — this looks like a js codegen gap rather than a typing issue.
## All targets tested
| | `1 + null` | `add_inline(1)` | `add(1)` |
|---|---|---|---|
| js | `1` | `1` | `NaN` |
| python | `TypeError` (crash) | `1null` | `1null` |
| eval (`--interp`) | throws | throws | throws |
eval raises `Uncaught exception Invalid operation: 1 + null` — i.e. the
reference interpreter treats this expression as invalid, while js silently
produces a value of the wrong runtime type.
The bare literal `1 + null` is separately wrong on both js (`1`) and python
(`TypeError`); only the function forms on python match the declared type.
Nothing warns at compile time: default flags, and `@:nullSafety(StrictThreaded)`
on the class, both compile with exit 0 and no diagnostics.
## Version
Haxe 4.3.4 (macOS) and haxe_2026-07-10_development_5f83789.tar.gz.
## Possibly related
- #4555 — `"Result = " + o` with `o:Null` fails at runtime (open)
- #8627 — abstract + String concat generates incorrect code (open)
- #12975 — `[interp] Std.string((null:UInt))` throws `Invalid operation: null + 0` (open)
Contributor guide
Research direction
Start with the Test.hx reproducer and run `haxe -main Test -js out.js && node out.js` to compare the literal, inline, and called forms. Trace the JavaScript backend code generation for optional arguments and addition; done means the called and inlined forms agree and produce the declared String result, with a regression test covering the behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100