Skip to content

fix: an empty string is no longer coerced to 0 in numeric contexts (HF-361) - #1768

Open
marcin-kordas-hoc wants to merge 9 commits into
developfrom
fix/hf-361-empty-string-coercion
Open

marcin-kordas-hoc wants to merge 9 commits into
developfrom
fix/hf-361-empty-string-coercion

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Context

HyperFormula read an empty string ("") as 0 wherever it expected a number: =""+0 returned 0 and =COUNT("") returned 1. In Excel "" is text, so the first formula returns #VALUE! and the second returns 0.

ArithmeticHelper.coerceNonDateScalarToMaybeNumber returned 0 for '' as a special case. This PR removes the special case, so '' goes through the normal text-to-number parsing, which finds no number. A blank cell is a different value (EmptyValue) and still reads as 0, so evaluateNullToZero is unaffected.

Aggregations ignore text found through a reference or a range, so =SUM(A1), with A1 holding "", stays 0. That check recognized only a bare reference, so with the coercion fix alone, =SUM((A1)) reached the coercion and became #VALUE!. NumericAggregationPlugin now looks through parentheses before it decides whether an argument is a reference. A parenthesized reference therefore behaves like the bare one whatever the cell holds, not only for "". With A1 holding abc, =SUM((A1)) changes from #VALUE! to 0. With A1 empty, =COUNT((A1)) changes from 1 to 0.

This is a breaking change for 4.0. The new migration guide, docs/guide/migration-from-3.x-to-4.0.md, lists what changes and what does not.

How did you test your changes?

  • Paired branch fix/hf-361-empty-string-coercion in hyperformula-tests (hyperformula-tests#53). It changes 12 spec files: the new unit/interpreter/empty-string-coercion.spec.ts, the specs of the functions whose expectations changed, and parenthesized-reference tests in the SUM, COUNT and AVERAGE specs.
  • The 182 tests in those 12 files pass. With ArithmeticHelper.ts and NumericAggregationPlugin.ts reverted to develop, 29 of them fail.
  • The HyperFormula values below come from a script run against this branch and against develop.

Compatibility with Excel and Google Sheets

Excel values were measured in Excel Online. Google Sheets was not checked. A1 holds an empty string; C1:C3 holds 1, "", 2.

Formula Excel Google Sheets HyperFormula
=""+0 #VALUE! not checked #VALUE! (was 0)
=A1+1 #VALUE! not checked #VALUE! (was 1)
=ROUND(A1,0), =ABS(A1) #VALUE! not checked #VALUE! (was 0)
=LN(A1) #VALUE! not checked #VALUE! (was #NUM!)
=COUNT("") 0 not checked 0 (was 1)
=SUM(""), =AVERAGE(""), =MIN(""), =MAX(""), =PRODUCT("") #VALUE! not checked #VALUE! (was 0)
=SUM(C1:C3,"") #VALUE! not checked #VALUE! (was 3)
=SUM(IF(TRUE(),"",1)) #VALUE! not checked #VALUE! (was 0)
=COUNT(IF(TRUE(),"",1)) 0 not checked 0 (was 1)
=SUM(LEFT("abc",0)) #VALUE! not checked #VALUE! (was 0)
=SUM(A1), =COUNT(A1) 0 not checked 0 (unchanged)
=AVERAGE(A1) #DIV/0! not checked #DIV/0! (unchanged)
=SUM(C1:C3) 3 not checked 3 (unchanged)
=SUM({1,"",2}) 3 not checked 3 (unchanged)

One case moves away from Excel. Excel ignores text in an argument it evaluates as an array, and HyperFormula agreed only because it read "" as 0:

Formula Excel Google Sheets HyperFormula
=SUM(IF(G1:G3>0,"",G1:G3)), G1:G3 = 1, 2, 3 0 not checked #VALUE! (was 0)
=COUNT(IF({1,0},"",5)) 1 not checked 0 (was 1)

HyperFormula's IF does not return an array here: =ROWS(IF(G1:G3>0,"",G1:G3)) is 1. =SUM(IF(G1:G3>5,"",G1:G3)) is 6 in Excel and 1 in HyperFormula, before and after this PR. The migration guide documents this case.

The parenthesized-reference results (=SUM((A1)) with A1 holding abc, =COUNT((A1)) with A1 empty) have not been measured in Excel yet.

Public API changes

No method or option changes. Formula results change as listed above; the changelog marks the entry as a breaking change.

Out of scope and follow-ups

  • =PRODUCT(A1) with A1 empty or holding "" returns 1; Excel returns 0. Not changed here.
  • =COUNTIF(C1:C3,"") counts only blank cells, so it returns 0 for the range above; Excel returns 1. Not changed here.
  • Open design question: should writing '' into a cell create a blank cell instead of a text cell? That would also change ISTEXT, COUNTA and LEN on such cells. This PR leaves cell parsing alone.
  • [BREAKING CHANGE] Make INDEX Excel-compatible for zero and omitted indices (HF-224) #1754 adds the same docs/guide/migration-from-3.x-to-4.0.md file, so whichever PR merges second resolves a conflict there.

Types of changes

  • Breaking change (existing functionality no longer works as before) – agreed with the maintainers
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues

  1. HF-361

Checklist

🤖 Generated with Claude Code


Note

High Risk
Breaking formula semantics across arithmetic, aggregations, and parenthesized references; existing sheets and integrations that rely on "" as zero or write '' for blank cells may get new errors or different results.

Overview
Breaking change for 4.0: HyperFormula no longer treats an empty string as 0 in arithmetic and other numeric contexts, aligning with Excel (=""+0 and cell "" in =A1+1 now yield #VALUE! instead of numeric results). Blank cells (EmptyValue / null) still coerce to zero; aggregations over bare references or ranges (=SUM(A1), =SUM(A1:C1)) are unchanged.

ArithmeticHelper.coerceNonDateScalarToMaybeNumber drops the '' → 0 shortcut so literals and evaluated "" flow through normal text parsing. NumericAggregationPlugin.reduce unwraps parenthesized AST nodes so (A1) is handled like A1—text/empty values in referenced cells are ignored in SUM/COUNT/etc., fixing cases where =SUM((A1)) incorrectly coerced or counted.

Docs add a 3.x → 4.0 migration guide (tables for what changes vs. what does not, app guidance to use null instead of '' when clearing cells) and a changelog entry under Changed.

Reviewed by Cursor Bugbot for commit cc66c2a. Bugbot is set up for automated code reviews on this repo. Configure here.

`ArithmeticHelper#coerceNonDateScalarToMaybeNumber` special-cased an
empty string ('') to coerce to 0, the same as a truly blank cell
(EmptyValue). This is a different case from EmptyValue -- a cell or
literal holding '' is text, not blank -- and it made HyperFormula
diverge from Excel in two ways:

- Arithmetic on '' returned 0 instead of #VALUE! (e.g. `=""+0`).
- COUNT("") returned 1 instead of 0, since the literal argument was
  coerced to 0 (an ExtendedNumber) before COUNT's own
  isExtendedNumber predicate ever saw it.

Root cause:
- Interpreter.ts `plusOp`/`minusOp`/etc. call
  `ArithmeticHelper#coerceScalarToNumberOrError`, which returned 0
  for '' before this fix.
- NumericAggregationPlugin#reduce (used by COUNT, SUM, AVERAGE,
  PRODUCT, MIN, MAX) calls the same coercion for a literal/expression
  argument (as opposed to a direct cell reference, which uses the
  aggregation's own predicate without numeric coercion) -- so
  COUNT("") went through the same 0-producing bug before
  isExtendedNumber(0) counted it.

This removes the `arg === ''` special case, letting '' fall through
to the normal string-to-number parsing (which correctly fails for
non-numeric text), matching how HyperFormula already treats any
other non-numeric string literal (e.g. `SUM("abc")` already returned
#VALUE!; `SUM("")` was the sole inconsistent exception).

Not affected (verified with tests): blank cells (EmptyValue) still
coerce to 0 in arithmetic and are still governed separately by
`evaluateNullToZero`; a '' criterion in COUNTIF/SUMIF-style functions
still matches only genuinely blank cells (Criterion.ts resolves that
case before any numeric coercion runs).

This is a breaking change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@qunabu

qunabu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
hyperformula-docs cc66c2a Commit Preview URL

Branch Preview URL
Oct 23 2026, 08:24 AM

The fix itself was already in place; this adds what a breaking change owes
the reader, and moves the tests where this repo keeps them.

- Changelog: moved the entry from Fixed to Changed and rewrote it in the
  "A **breaking change**:" form the other 4.0 entries use, with a migration
  guide link and the PR number instead of an internal task id.
- Migration guide: new docs/guide/migration-from-3.x-to-4.0.md (plus its
  sidebar entry) with a section on empty-string coercion. Every before/after
  value in it is measured, not assumed -- two rounds of probes across the
  unpatched and patched engine, plus the assertions the private suite already
  pinned. That caught two wrong claims: DATE("","","") returned #NUM! and not
  a computed date, and ACOT("") returned 1.5707963267949 -- a plausible number
  from an argument that was never numeric, which is the most useful example
  this change has.
- Removed test/hf-361-empty-string-coercion.spec.ts. DEV_DOCS.md:129 is
  explicit that interpreter specs do not live in the public repo, because a
  copy here runs twice and leaves develop red if only one of the paired PRs
  lands. Those tests move to the paired hyperformula-tests branch of the same
  name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Performance comparison of head (cc66c2a) vs base (d143456)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  426.91 |  425.94 | -0.23%
                                      Sheet B |  131.53 |  131.39 | -0.11%
                                      Sheet T |  118.97 |  116.89 | -1.75%
                                Column ranges |  564.03 |  566.31 | +0.40%
                                Sorted lookup | 17755.2 | 17438.7 | -1.78%
Sheet A:  change value, add/remove row/column |   12.09 |    11.6 | -4.05%
 Sheet B: change value, add/remove row/column |  114.71 |  107.11 | -6.63%
                   Column ranges - add column |   147.9 |  144.79 | -2.10%
                Column ranges - without batch |  465.38 |  461.26 | -0.89%
                        Column ranges - batch |  119.39 |  113.22 | -5.17%

…n guide

Two independent reviews converged on the same defect: both documents implied
that a cell holding an empty string breaks any numeric formula. It does not.
Measured on both engines: SUM, AVERAGE, MIN, MAX and COUNT over a reference
or a range are byte-identical before and after -- they have always ignored
text found there, matching Excel -- because the aggregation path filters
strings out before this coercion is reached. Only arithmetic, number-typed
arguments, and an empty string written directly into an aggregation change.
A reader with =SUM(A1:A100) would have been told to expect a break that
cannot happen, in a document whose whole job is to predict breaks.

Also corrected or added, all measured rather than read off a test:

- =ACOT("") returned 1.5707963268, not 1.5707963267949. I had taken the
  digits from the private suite's toBeCloseTo(..., 10) assertion, which
  passes for both. Default config rounds to 10 significant digits.
- =DATE(A1, 2, 3) over a text empty string silently returned 35 -- a date in
  1900 -- which is the same "answered instead of failing" class as ACOT and a
  better example, because it is the reference form users actually write.
- Added the contexts the table omitted while claiming to be exhaustive:
  =""%, =SUM(1, ""), =ABS, =INT, =SUMSQ, =MEDIAN, =STDEV.
- Added the nested-expression class (=SUM(IF(TRUE,"",1)), =SUM(LEFT("abc",0))),
  where 3.x was wrong and 4.0 matches Excel, and the one class that moves away
  from Excel (an argument Excel evaluates as an array of only text), with the
  pre-existing non-array-aware IF that actually causes it.
- Fixed the code blocks against DOCS_CONTENT_GUIDE: the mandated
  { sheet, row, col } key order, a snippet that actually runs as written
  (verified), and an untagged fence for the before/after pair that is not
  JavaScript. Pointed N() and IFERROR at their real category anchors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@marcin-kordas-hoc
marcin-kordas-hoc marked this pull request as ready for review September 10, 2026 15:37

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c99427b. Configure here.

Comment thread docs/guide/migration-from-3.x-to-4.0.md

@Tobiadefami Tobiadefami left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we preserve reference handling through parentheses? With B1 containing ="", =SUM((B1)) returns 0 in Excel Online and on base, but #VALUE! on this head.

A possible fix is to recursively look through parentheses when checking for CELL_REFERENCE in NumericAggregationPlugin.reduce(). I tested this temporarily: it fixes the regression while preserving rejection of literal empty strings. All 195 tests across 16 checked suites passed.

…eric aggregations

Reported in review on this PR: with B1 holding ="", SUM(B1) correctly returns
0 (aggregation over a reference ignores non-numeric text, unchanged by this
PR), but SUM((B1)) returned #VALUE!.

Root cause: NumericAggregationPlugin#reduce decides how to treat an argument
by checking arg.type against CELL_REFERENCE (and separately against
CELL_RANGE/COLUMN_RANGE/ROW_RANGE) to tell "this is a reference, ignore its
non-numeric text" apart from "this is a literal/computed value, coerce it to
a number." A parenthesized argument reaches the reducer as a PARENTHESIS node
wrapping the real expression, so neither check matched and (B1) fell through
to the same coercion "" now correctly fails for as a literal.

reduce() is the single shared implementation behind SUM, SUMSQ, COUNT,
COUNTA, AVERAGE, AVERAGEA, MIN, MAX, MINA, MAXA, PRODUCT, VAR.S/P, VARA/VARPA
and STDEV.S/P/A/PA (via doAverage/doCount/reduceAggregate(A)/doMax/doMin/
doSum/doProduct), so this was not SUM-specific: every one of those reproduced
the same #VALUE! regression through a single extra pair of parentheses.

Fix: unwrap PARENTHESIS nodes before either arg.type check, the same idiom
already used in FunctionPlugin#runFunctionWithReferenceArgument and
InformationPlugin. PARENTHESIS is a pure passthrough in
Interpreter#evaluateAst, so this only changes which branch classifies the
argument, not what evaluateAst(arg, state) itself returns.

Verified against Excel Online via MS Graph: a parenthesized reference or
range behaves identically to the bare one in every case tested (single and
double parentheses, SUM/COUNT/AVERAGE/MIN/MAX/PRODUCT/SUMSQ/STDEV, a range
containing an embedded empty string). Ranges wrapped in parentheses already
worked before this fix -- they take a different path in reduce() that checks
the evaluated value's runtime type (SimpleRangeValue) rather than the AST
node type -- so this fix does not change range handling, only single-cell
(and column/row) references.

Paired failing-then-passing tests: handsontable/hyperformula-tests, same
branch, "parentheses around a reference do not change numeric-aggregation
behavior" in empty-string-coercion.spec.ts. Negative control: reverting this
commit's source change while keeping those tests fails 5 of the 8 new specs
(the 3 that also pass without the fix are pins on already-correct behavior:
COUNT((A1)) and the two parenthesized-range cases).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

@Tobiadefami reproduced and fixed — thanks for catching it, and sorry it sat this long.

SUM((B1)) was going through the same numeric-coercion path a literal gets, because (B1) reaches NumericAggregationPlugin#reduce as a PARENTHESIS AST node wrapping the CELL_REFERENCE, and the reducer's reference check only matched a bare CELL_REFERENCE node. Fixed by unwrapping PARENTHESIS before that check — the same pattern already used in FunctionPlugin#runFunctionWithReferenceArgument and InformationPlugin — so it's a one-line fix at a single shared choke point rather than a per-function patch.

That choke point (reduce()) is the shared implementation behind the whole numeric-aggregation family, not just SUM: COUNT, AVERAGE, MIN, MAX, PRODUCT, SUMSQ, STDEV.S/P (and their A-suffixed variants) all reproduced the identical #VALUE! regression through one extra pair of parentheses, and all are fixed by this single change. MIN/MAX weren't in your report but I checked them too since they share reduce() — same bug, same fix.

Verified against Excel Online (MS Graph, live session): parentheses are fully transparent to reference handling in every case I measured — single and double parens, the whole aggregation family, and a parenthesized range containing an embedded "". Your reading was exactly right.

Added 8 specs to the paired repo (empty-string-coercion.spec.ts), written and confirmed failing against the previous head before the fix existed, passing after, and reconfirmed failing with the source fix reverted and the tests kept (negative control). Local Jest: 503/503 suites, 6279 passing.

One caveat I want to be explicit about: I could not run the Karma/Jasmine browser leg locally — no headless browser in my environment — so I pushed rather than claim it green. CI is running it now on a24a523dd; please treat the browser result there as the real verdict, not my local run.

MEDIAN does not share this bug (different code path, no reference-detection special case at all) — flagging separately rather than folding it in here.

@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

DROP and TAKE (open in #1796 and #1722) depend on this change too. On the #1796 head an empty-string count gives DROP(A1:C3,"") the whole array and TAKE(A1:C3,"") #N/A. With this PR's src/ change applied on top, both return #VALUE! for row and column counts, which matches Excel Online. Whichever of the three merges last should add those cases to its specs.

marcin-kordas-hoc and others added 2 commits October 22, 2026 17:31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

Fixed in a24a523, along the lines you suggested: NumericAggregationPlugin.reduce() now looks through parentheses before deciding whether an argument is a reference. With B1 holding ="", =SUM((B1)) returns 0 again, and so do COUNT, MIN and MAX, while AVERAGE returns #DIV/0!. Nested parentheses and parenthesized ranges behave the same. The cases are pinned in hyperformula-tests#53 (empty-string-coercion.spec.ts).

The branch is now up to date with develop. Could you take another look?

marcin-kordas-hoc and others added 3 commits October 23, 2026 00:29
…and the migration guide

Drop two search tags that repeat the page's own heading, and shorten the
comment on the parenthesis unwrap to the behaviour it implements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rFormula needs

`=SUM(IF(TRUE,"",1))` returns #NAME? in HyperFormula, because TRUE is a
function there, not a literal; the table's values hold for `TRUE()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.28%. Comparing base (d143456) to head (cc66c2a).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           develop    #1768   +/-   ##
========================================
  Coverage    97.28%   97.28%           
========================================
  Files          207      207           
  Lines        16218    16218           
  Branches      3556     3587   +31     
========================================
  Hits         15777    15777           
  Misses         433      433           
  Partials         8        8           
Files with missing lines Coverage Δ
hyperformula/src/interpreter/ArithmeticHelper.ts 98.66% <ø> (-0.01%) ⬇️
...src/interpreter/plugin/NumericAggregationPlugin.ts 97.25% <100.00%> (+0.01%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants