diff --git a/CHANGELOG.md b/CHANGELOG.md
index 323c746b2d..da7992e901 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Fixed
- Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781)
+- Fixed approximate `XLOOKUP` ignoring values of a different type in some search modes, and approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` taking such values into account for non-range arrays, single-cell ranges, and binary search over sorted cell ranges; both now behave the same in linear and binary search, consistent with Microsoft Excel. [#1755](https://github.com/handsontable/hyperformula/pull/1755)
## [3.5.0] - 2026-10-09
diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md
index 660fda0f98..02bf1addd4 100644
--- a/docs/guide/list-of-differences.md
+++ b/docs/guide/list-of-differences.md
@@ -53,6 +53,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google
| EOMONTH function | =EOMONTH(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** |
| EDATE and EOMONTH results in February 1900 | =EDATE(DATE(1900, 1, 31), 1)
=EOMONTH(DATE(1900, 1, 31), 1) | With the [`leapYear1900`](/api/interfaces/configparams.md#leapyear1900) option set to `true`, February 1900 has 29 days, so both examples return 29 February 1900 (serial `60` with `nullDate` set to 1899-12-31). With the default `false`, both return 28 February 1900. | 28 February 1900 for both examples. Google Sheets does not treat 1900 as a leap year. | 28 February 1900 (serial `59`) for both examples. Excel keeps the 1900 leap day only in the serial-to-date mapping (`DAY(60)` is `29`); its month arithmetic treats February 1900 as having 28 days, so no `EDATE` or `EOMONTH` result is ever serial `60`. |
| Empty cells in lookup search | =XLOOKUP(3, A1:A4, A1:A4, "NF", 0, 2)
where A1:A4 = 1, 2, (empty), 3 | Empty cells are skipped during the search. A value that is present is found even when it sits past an interspersed empty cell (exact match is gap-independent), and approximate `MATCH`/`VLOOKUP`/`HLOOKUP`/`XLOOKUP` skip empty cells — but not empty strings — when finding the lower/upper bound. Returns `3`. On an all-empty range in a binary search mode, HyperFormula returns the `if_not_found` result (never row 1). | Skips empty cells in approximate search (parity with HyperFormula). | With binary search modes (`search_mode` ±2), a range with interspersed empty cells is not strictly sorted; per Excel's documentation the result may be invalid, so a value past an empty cell is not reliably found. On an all-empty range in a binary mode, Excel returns the first row's value. |
+| Approximate lookup matching across value types | =XLOOKUP("a", A1:A2, B1:B2, "NF", -1, 1)
where A1:A2 = 10, 20 and B1:B2 = "x", "y" | Approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` skip candidates whose type differs from the lookup value, in both linear and binary search. Approximate `XLOOKUP` follows Microsoft Excel's ordering of numbers, text, logical values, and errors; empty cells are skipped (see *Empty cells in lookup search*). Returns `"y"` for the example. | Approximate lookups ignore candidates whose type differs from the lookup value. Returns `"NF"` for the example. | Same as HyperFormula, except that linear approximate `XLOOKUP` ranks an empty cell above every value: `=XLOOKUP("z", A1:A4, B1:B4, "NF", 1, 1)` where A1:A4 = 10, (empty), 20, "b" returns B2. |
## Built-in functions
diff --git a/hyperformula/src/Lookup/AdvancedFind.ts b/hyperformula/src/Lookup/AdvancedFind.ts
index 5c5943cde5..35dad717e0 100644
--- a/hyperformula/src/Lookup/AdvancedFind.ts
+++ b/hyperformula/src/Lookup/AdvancedFind.ts
@@ -12,7 +12,7 @@ import {
RawNoErrorScalarValue
} from '../interpreter/InterpreterValue'
import {SimpleRangeValue} from '../SimpleRangeValue'
-import {AdvancedFindOptions, SearchOptions} from './SearchStrategy'
+import {AdvancedFindOptions, ApproximateMatchPolicy, SearchOptions} from './SearchStrategy'
import {forceNormalizeString} from '../interpreter/ArithmeticHelper'
import {compare, findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch'
@@ -29,7 +29,7 @@ export abstract class AdvancedFind {
const values: InternalScalarValue[] = (range === undefined)
? rangeValue.valuesFromTopLeftCorner()
: this.dependencyGraph.computeListOfValuesInRange(range)
-
+
const initialIterationIndex = returnOccurrence === 'first' ? 0 : values.length-1
const iterationCondition = returnOccurrence === 'first' ? (i: number) => i < values.length : (i: number) => i >= 0
const incrementIndex = returnOccurrence === 'first' ? (i: number) => i+1 : (i: number) => i-1
@@ -42,22 +42,22 @@ export abstract class AdvancedFind {
return NOT_FOUND
}
- protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number {
+ protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, approximateMatchPolicy = 'sameType', returnOccurrence }: SearchOptions): number {
const normalizedSearchKey = typeof searchKey === 'string' ? forceNormalizeString(searchKey) : searchKey
const range = rangeValue.range
if (range === undefined) {
- return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, returnOccurrence)
+ return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, approximateMatchPolicy, returnOccurrence)
}
if (ordering === 'none') {
- return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, returnOccurrence)
+ return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, approximateMatchPolicy, returnOccurrence)
}
return findLastOccurrenceInOrderedRange(
normalizedSearchKey,
range,
- { searchCoordinate, orderingDirection: ordering, ifNoMatch },
+ { searchCoordinate, orderingDirection: ordering, ifNoMatch, approximateMatchPolicy },
this.dependencyGraph
)
}
@@ -67,9 +67,27 @@ export abstract class AdvancedFind {
* is `returnLowerBound`/`returnUpperBound` — the closest non-exceeding/non-preceding value.
* Genuinely empty cells (`EmptyValue`) are skipped, consistent with `findLastOccurrenceInOrderedRange`
* and with Excel/Google Sheets, which ignore empty cells (but not empty strings) in approximate search.
- * Returns the 0-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches.
+ * Other cross-type candidates are either ignored or compared using the total ordering, according to
+ * `approximateMatchPolicy`.
+ *
+ * @param {RawNoErrorScalarValue} searchKey - Normalized lookup value.
+ * @param {InternalScalarValue[]} searchArray - Values to search from the top-left corner of the lookup array.
+ * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result.
+ * @param {ApproximateMatchPolicy} approximateMatchPolicy - Cross-type candidate policy for approximate bounds.
+ * @param {('first'|'last')} returnOccurrence - Which exact duplicate to return and the direction used to scan candidates.
+ * @returns {number} The zero-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches.
+ *
+ * Candidate selection:
+ *
+ * Approximate candidate selection remains in the `NOT_FOUND` index state until the first eligible
+ * value seeds the result. A scalar sentinel such as positive or negative infinity would participate
+ * in `compare()` and is not type-neutral: numeric infinity is not a universal extremum under the
+ * number/string/boolean total ordering. Keeping absence in the index domain ensures that only actual
+ * lookup values are compared.
+ *
+ * @internal
*/
- protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number {
+ protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: ApproximateMatchPolicy, returnOccurrence: 'first' | 'last' = 'first'): number {
const normalizedArray = searchArray
.map(getRawValue)
.map(val => typeof val === 'string' ? forceNormalizeString(val) : val)
@@ -82,7 +100,6 @@ export abstract class AdvancedFind {
? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right)
: (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right)
- let bestValue: RawNoErrorScalarValue = ifNoMatch === 'returnLowerBound' ? -Infinity : Infinity
let bestIndex = NOT_FOUND
const initialIterationIndex = returnOccurrence === 'first' ? 0 : normalizedArray.length-1
@@ -103,12 +120,15 @@ export abstract class AdvancedFind {
continue
}
+ if (approximateMatchPolicy === 'sameType' && typeof value !== typeof searchKey) {
+ continue
+ }
+
if (compareFn(value, searchKey) > 0) {
continue
}
-
- if (compareFn(bestValue, value) < 0) {
- bestValue = value
+
+ if (bestIndex === NOT_FOUND || compareFn(normalizedArray[bestIndex] as RawNoErrorScalarValue, value) < 0) {
bestIndex = i
}
}
diff --git a/hyperformula/src/Lookup/ColumnIndex.ts b/hyperformula/src/Lookup/ColumnIndex.ts
index d7c32e5894..ea3fc27f36 100644
--- a/hyperformula/src/Lookup/ColumnIndex.ts
+++ b/hyperformula/src/Lookup/ColumnIndex.ts
@@ -107,13 +107,11 @@ export class ColumnIndex implements ColumnSearchStrategy {
}
}
- public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number {
- if (returnOccurrence == null) {
- returnOccurrence = ordering === 'none' ? 'first' : 'last'
- }
+ public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchOptions: SearchOptions): number {
+ const returnOccurrence = searchOptions.returnOccurrence ?? (searchOptions.ordering === 'none' ? 'first' : 'last')
const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, returnOccurrence)
- return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, ifNoMatch, returnOccurrence })
+ return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ...searchOptions, returnOccurrence })
}
private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, returnOccurrence: 'first' | 'last'): Maybe {
diff --git a/hyperformula/src/Lookup/SearchStrategy.ts b/hyperformula/src/Lookup/SearchStrategy.ts
index 2316e586c9..b0a672c66f 100644
--- a/hyperformula/src/Lookup/SearchStrategy.ts
+++ b/hyperformula/src/Lookup/SearchStrategy.ts
@@ -14,9 +14,38 @@ import {Statistics} from '../statistics'
import {ColumnBinarySearch} from './ColumnBinarySearch'
import {ColumnIndex} from './ColumnIndex'
+/**
+ * Selects how approximate lower and upper bounds handle non-empty candidates whose scalar type
+ * differs from the lookup value's type.
+ *
+ * Mode semantics:
+ *
+ * - `'sameType'` accepts only candidates with the same JavaScript scalar type as the lookup value.
+ * `MATCH`, `VLOOKUP`, and `HLOOKUP` use this policy.
+ * - `'totalOrder'` accepts cross-type candidates and orders them with the lookup comparator.
+ * `XLOOKUP` uses this policy to match Microsoft Excel.
+ * - Exact matches are unaffected. Every search strategy resolves the policy in one place
+ * (`AdvancedFind.basicFind`), so linear, indexed, and binary paths cannot diverge.
+ *
+ * When `SearchOptions.approximateMatchPolicy` is omitted, `'sameType'` is used.
+ *
+ * @internal
+ */
+export type ApproximateMatchPolicy = 'sameType' | 'totalOrder'
+
+/**
+ * Defines the lookup semantics passed to a search strategy.
+ *
+ * @property {('asc'|'desc'|'none')} ordering - Ordering assumed by the selected search algorithm.
+ * @property {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Result requested when an exact value is absent.
+ * @property {ApproximateMatchPolicy} [approximateMatchPolicy='sameType'] - Cross-type candidate policy for approximate bounds; exact matches ignore it.
+ * @property {('first'|'last')} [returnOccurrence] - Which exact duplicate to return.
+ * @internal
+ */
export interface SearchOptions {
ordering: 'asc' | 'desc' | 'none',
ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound',
+ approximateMatchPolicy?: ApproximateMatchPolicy,
returnOccurrence?: 'first' | 'last',
}
diff --git a/hyperformula/src/interpreter/binarySearch.ts b/hyperformula/src/interpreter/binarySearch.ts
index 01e7b07350..99c896181a 100644
--- a/hyperformula/src/interpreter/binarySearch.ts
+++ b/hyperformula/src/interpreter/binarySearch.ts
@@ -6,18 +6,14 @@
import {AbsoluteCellRange} from '../AbsoluteCellRange'
import {CellError, simpleCellAddress} from '../Cell'
import {DependencyGraph} from '../DependencyGraph'
+import {ApproximateMatchPolicy} from '../Lookup/SearchStrategy'
import {EmptyValue, getRawValue, RawInterpreterValue, RawNoErrorScalarValue} from './InterpreterValue'
const NOT_FOUND = -1
-/*
+/**
* Searches for the searchKey in a sorted 1-D range.
*
- * Options:
- * - searchCoordinate - must be set to either 'row' or 'col' to indicate the dimension of the search,
- * - orderingDirection - must be set to either 'asc' or 'desc' to indicate the ordering direction for the search range,
- * - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound'
- *
* If the search range contains duplicates, returns the last matching value, with one caveat: in the
* 'returnNotFound' mode, when the range contains both duplicates of the searchKey and interspersed
* empty cells, the search may report any of the duplicated positions (Excel's binary-search modes
@@ -30,19 +26,42 @@ const NOT_FOUND = -1
* ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound;
* for Excel's explicit binary search modes (XLOOKUP search_mode ±2) the result on a range with
* interspersed empty cells is unspecified — see docs/guide/list-of-differences.md.
- * The returned offset is always relative to the original range, so empty cells keep their slots
- * and the position of the matched non-empty cell is reported unchanged.
+ * Under the 'sameType' policy, cells whose type differs from the searchKey's are skipped the same
+ * way, as Excel's MATCH/VLOOKUP/HLOOKUP drop them before searching.
+ * The returned offset is always relative to the original range, so skipped cells keep their slots
+ * and the position of the matched cell is reported unchanged.
*
- * Complexity: O(log n) as long as the binary-search descent probes no empty cell — in particular
- * for ranges without empty cells. Only when the descent touches an empty cell does the search fall
- * back to an O(n) scan that compacts the non-empty indices and re-runs the binary search over them.
+ * Complexity: O(log n) as long as the binary-search descent probes no skipped cell — in particular
+ * for ranges without empty cells (and, under 'sameType', without cross-type cells). Only when the
+ * descent touches a skipped cell does the search fall back to an O(n) scan that compacts the
+ * eligible indices and re-runs the binary search over them.
*
* Note: this function does not normalize input strings.
+ *
+ * @param {RawNoErrorScalarValue} searchKey - Lookup value.
+ * @param {AbsoluteCellRange} range - Sorted one-dimensional cell range.
+ * @param {object} options - Search direction, requested miss result, and approximate-match policy.
+ * @param {('row'|'col')} options.searchCoordinate - Dimension containing the one-dimensional lookup values.
+ * @param {('asc'|'desc')} options.orderingDirection - Ascending or descending ordering assumed by the binary search.
+ * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} options.ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result.
+ * @param {ApproximateMatchPolicy} options.approximateMatchPolicy - Cross-type candidate policy for approximate bounds.
+ * @param {DependencyGraph} dependencyGraph - Source of current cell values for the range.
+ * @returns {number} The range-relative index of the match, or `NOT_FOUND` when no valid result exists.
+ *
+ * Approximate result invariant:
+ *
+ * Exact equality is resolved before approximate policy validation. Under 'sameType', cross-type
+ * cells are excluded from the descent itself — not only from the landing cell — so the binary
+ * route selects among the same candidates as the linear route, which skips them before comparing.
+ * Every approximate exit path, including candidates reached by stepping over skipped cells, is
+ * finalized through the same policy check.
+ *
+ * @internal
*/
export function findLastOccurrenceInOrderedRange(
searchKey: RawNoErrorScalarValue,
range: AbsoluteCellRange,
- { searchCoordinate, orderingDirection, ifNoMatch }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' },
+ { searchCoordinate, orderingDirection, ifNoMatch, approximateMatchPolicy }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: ApproximateMatchPolicy },
dependencyGraph: DependencyGraph,
): number {
const start = range.start[searchCoordinate]
@@ -57,13 +76,20 @@ export function findLastOccurrenceInOrderedRange(
: (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right)
/*
- * Returns the original index of the first non-empty cell at or after fromIndex, or undefined if
- * all the remaining cells are empty. Costs O(gap length), which the search pays only when it
- * actually needs to step over empty slots.
+ * A cell is skipped by the approximate search when it is empty or, under the 'sameType' policy,
+ * holds a value of a different type than the searchKey. Every other cell is eligible.
*/
- const findNextNonEmptyIndex = (fromIndex: number): number | undefined => {
+ const isSkipped = (value: RawInterpreterValue): boolean =>
+ value === EmptyValue || (approximateMatchPolicy === 'sameType' && typeof value !== typeof searchKey)
+
+ /*
+ * Returns the original index of the first eligible cell at or after fromIndex, or undefined if
+ * no eligible cell remains. Costs O(gap length), which the search pays only when it actually
+ * needs to step over skipped slots.
+ */
+ const findNextEligibleIndex = (fromIndex: number): number | undefined => {
for (let index = fromIndex; index <= end; index++) {
- if (getValueFromIndexFn(index) !== EmptyValue) {
+ if (!isSkipped(getValueFromIndexFn(index))) {
return index
}
}
@@ -71,7 +97,8 @@ export function findLastOccurrenceInOrderedRange(
}
// Fast path: binary search directly over the original range, tracking whether the descent ever
- // probed an empty cell. Within the sorted-input contract, empty cells are the only source of
+ // probed a skipped cell (empty, or cross-type under 'sameType'; the reasoning below for empty
+ // cells applies to both). Within the sorted-input contract, empty cells are the only source of
// non-monotonicity in the search predicate: compare() ranks EmptyValue below every non-empty
// value, and genuinely empty cells surface as the EmptyValue sentinel — empty strings and 0 do
// not. (Error values also break the ordering, but a range containing errors is outside the
@@ -80,43 +107,65 @@ export function findLastOccurrenceInOrderedRange(
// justified by the monotonicity of the non-empty values, and the landing index — which is always
// probed — is the same one the compacted search below would return: the result can be trusted
// as-is. This keeps the search O(log n) unless an empty cell actually interferes.
- let probedEmptyCell = false
+ let probedSkippedCell = false
const directIndex = findLastMatchingIndex(index => {
const value = getValueFromIndexFn(index)
- if (value === EmptyValue) {
- probedEmptyCell = true
+ if (isSkipped(value)) {
+ probedSkippedCell = true
}
return compareFn(searchKey, value) >= 0
}, start, end)
let foundIndex: number
- if (!probedEmptyCell) {
+ if (!probedSkippedCell) {
foundIndex = directIndex
} else if (ifNoMatch === 'returnNotFound' && directIndex !== NOT_FOUND && getValueFromIndexFn(directIndex) === searchKey) {
// Exact-match mode: a misdirected descent cannot produce a false positive, because the landing
// value is re-checked for equality here. Accept the hit and skip the O(n) fallback.
return directIndex - start
} else {
- // The descent probed an empty cell, so its result cannot be trusted (HF-223): collect the
- // original indices of the non-empty cells (O(n)) and re-run the binary search over the
- // compacted, empty-free index list. The result maps back to the original index space, so empty
- // cells keep their slots and the matched non-empty cell's original position is reported
- // unchanged. On an all-empty range the compacted list has no elements and the search reports
- // NOT_FOUND, which the ifNoMatch branches below preserve.
- const nonEmptyIndices: number[] = []
+ // The descent probed a skipped cell, so its result cannot be trusted (HF-223): collect the
+ // original indices of the eligible cells (O(n)) and re-run the binary search over the
+ // compacted index list. The result maps back to the original index space, so skipped cells
+ // keep their slots and the matched cell's original position is reported unchanged. When no
+ // cell is eligible the compacted list has no elements and the search reports NOT_FOUND, which
+ // the ifNoMatch branches below preserve.
+ const eligibleIndices: number[] = []
for (let index = start; index <= end; index++) {
- if (getValueFromIndexFn(index) !== EmptyValue) {
- nonEmptyIndices.push(index)
+ if (!isSkipped(getValueFromIndexFn(index))) {
+ eligibleIndices.push(index)
}
}
- const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1)
- foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex]
+ const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(eligibleIndices[compactedIndex])) >= 0, 0, eligibleIndices.length - 1)
+ foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : eligibleIndices[foundCompactedIndex]
}
const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex)
+ /**
+ * Validates and converts an absolute approximate candidate index into a range-relative result.
+ *
+ * @param {(number|undefined)} index - Absolute candidate index, or `undefined` when no eligible candidate exists.
+ * @returns {number} The range-relative index, or `NOT_FOUND` when the candidate is absent or disallowed.
+ *
+ * @internal
+ */
+ const returnApproximateResult = (index: number | undefined): number => {
+ if (index === undefined || index === NOT_FOUND) {
+ return NOT_FOUND
+ }
+
+ // Safety net: every candidate reaching this point is already eligible, because the descent,
+ // the compaction, and findNextEligibleIndex all skip cross-type cells under 'sameType'.
+ if (approximateMatchPolicy === 'sameType' && typeof getValueFromIndexFn(index) !== typeof searchKey) {
+ return NOT_FOUND
+ }
+
+ return index - start
+ }
+
if (foundValue === searchKey) {
return foundIndex - start
}
@@ -128,25 +177,21 @@ export function findLastOccurrenceInOrderedRange(
}
// orderingDirection === 'desc': the key exceeds every value in the range, so the lower bound
- // is the first (largest) non-empty value — never an empty leading cell, and NOT_FOUND on an
- // all-empty range.
- const firstNonEmptyIndex = findNextNonEmptyIndex(start)
- return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND
- }
-
- if (typeof foundValue !== typeof searchKey) {
- return NOT_FOUND
+ // is the first (largest) eligible value — never a skipped leading cell, and NOT_FOUND when no
+ // cell is eligible.
+ const firstEligibleIndex = findNextEligibleIndex(start)
+ return returnApproximateResult(firstEligibleIndex)
}
// here: foundValue !== searchKey
if (orderingDirection === 'asc') {
- return foundIndex - start
+ return returnApproximateResult(foundIndex)
}
- // orderingDirection === 'desc': step to the next non-empty cell, so skipped empty slots never
- // shift the reported position.
- const nextIndex = findNextNonEmptyIndex(foundIndex + 1)
- return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
+ // orderingDirection === 'desc': step to the next eligible cell, so skipped slots never shift
+ // the reported position.
+ const nextIndex = findNextEligibleIndex(foundIndex + 1)
+ return returnApproximateResult(nextIndex)
}
if (ifNoMatch === 'returnUpperBound') {
@@ -156,25 +201,21 @@ export function findLastOccurrenceInOrderedRange(
}
// orderingDirection === 'asc': the key precedes every value in the range, so the upper bound
- // is the first (smallest) non-empty value — never an empty leading cell, and NOT_FOUND on an
- // all-empty range.
- const firstNonEmptyIndex = findNextNonEmptyIndex(start)
- return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND
- }
-
- if (typeof foundValue !== typeof searchKey) {
- return NOT_FOUND
+ // is the first (smallest) eligible value — never a skipped leading cell, and NOT_FOUND when no
+ // cell is eligible.
+ const firstEligibleIndex = findNextEligibleIndex(start)
+ return returnApproximateResult(firstEligibleIndex)
}
// here: foundValue !== searchKey
if (orderingDirection === 'desc') {
- return foundIndex - start
+ return returnApproximateResult(foundIndex)
}
- // orderingDirection === 'asc': step to the next non-empty cell, so skipped empty slots never
- // shift the reported position.
- const nextIndex = findNextNonEmptyIndex(foundIndex + 1)
- return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
+ // orderingDirection === 'asc': step to the next eligible cell, so skipped slots never shift
+ // the reported position.
+ const nextIndex = findNextEligibleIndex(foundIndex + 1)
+ return returnApproximateResult(nextIndex)
}
// ifNoMatch === 'returnNotFound'
diff --git a/hyperformula/src/interpreter/plugin/LookupPlugin.ts b/hyperformula/src/interpreter/plugin/LookupPlugin.ts
index 625a68735d..3b66243f53 100644
--- a/hyperformula/src/interpreter/plugin/LookupPlugin.ts
+++ b/hyperformula/src/interpreter/plugin/LookupPlugin.ts
@@ -90,7 +90,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech
const searchOptions: SearchOptions = {
ordering: sorted ? 'asc' : 'none',
- ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound'
+ ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound',
+ approximateMatchPolicy: 'sameType'
}
return this.doVlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions)
@@ -120,7 +121,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech
const searchOptions: SearchOptions = {
ordering: sorted ? 'asc' : 'none',
- ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound'
+ ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound',
+ approximateMatchPolicy: 'sameType'
}
return this.doHlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions)
@@ -149,6 +151,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech
const searchOptions: SearchOptions = {
ordering: searchMode === 2 ? 'asc' : searchMode === -2 ? 'desc' : 'none',
returnOccurrence: searchMode === -1 ? 'last' : 'first',
+ approximateMatchPolicy: 'totalOrder',
ifNoMatch: matchMode === -1
? 'returnLowerBound'
: matchMode === 1
@@ -310,8 +313,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech
const searchStrategy = rangeValue.width() === 1 ? this.columnSearch : this.rowSearch
const searchOptions: SearchOptions = type === 0
- ? { ordering: 'none', ifNoMatch: 'returnNotFound' }
- : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound' }
+ ? { ordering: 'none', ifNoMatch: 'returnNotFound', approximateMatchPolicy: 'sameType' }
+ : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound', approximateMatchPolicy: 'sameType' }
const index = searchStrategy.find(key, rangeValue, searchOptions)
if (index === -1) {