Excel does not advertise functions as first-class values. Yet a named lambda will happily accept a bare function itself as an argument — not merely its result — and let you use it later. Our example today will build a single, array-valued, FIND/SEARCH function with optional boolean predicates. Basic ones like AND and OR; an inconvenient one, XOR; and one that Excel doesn’t even have, NOR. Maybe two others if I can come up with a valid use-case. If this all sounds like a good time, keep reading.
Consider a named lambda with this signature:
=MATCHES.SOMEHOW(FindText, WithinText, TextFunction)
Which we want to use like this:
=MATCHES.SOMEHOW("cur?ous", "This is curious", SEARCH)
Let’s see how SEARCH arrives as the TextFunction argument and then try to call it with the other two arguments. If you’re not familiar with GROUPBY, you might be surprised to learn that it works at all.
How does it work? I’m glad you asked.
MATCHES.SOMEHOWv1 = LAMBDA(FindText, WithinText, TextFunction,
ISNUMBER(TextFunction(FindText, WithinText))
);
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv1("cur?ouser", "curiouser", FIND) |
| 2 | TRUE | =MATCHES.SOMEHOWv1("cur?ouser", "curiouser", SEARCH) |
Let’s break our cool new toy
One successful formula does not establish the rules. We need to probe which functions have been passed, how Excel handles arity and omitted arguments, and what its errors reveal about the value being carried.
At some point, someone will receive your spreadsheet and start passing functions all willy-nilly like. Then what happens? Problems. In this case, ISNUMBER() will swallow up just about any error, happily providing the user false-negatives. In my experience, spreadsheet consumers can be, quite rightfully, displeased when their formulae start lying to them.
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv1("cur?ouser", "curiouser", RAND) |
Excel does not appreciate our faux first-class functions, and therefore provides no facilities to inspect them. Instead, we must resort to behavioral probing to determine if we’ve received a compatible function. Let’s add inline validation to resolve either an accurate result or a #NAME? error.
MATCHES.SOMEHOWv2 = LAMBDA(FindText, WithinText, TextFunction,
LET(
_isValidTextFunction?, LET(
_isFind, IFERROR(
TextFunction("*ef", "abc*ef") = 4,
FALSE
),
_isSearch, IFERROR(
TextFunction("*ef", "abc*ef") = 1,
FALSE
),
OR(_isFind, _isSearch)
),
IF(
_isValidTextFunction?,
ISNUMBER(TextFunction(FindText, WithinText)),
#NAME?
)
)
);
The setup is quite simple. We only want a user passing FIND or SEARCH, so we apply a well-known input to both expecting a deterministic result. If any come out TRUE it’s safe to proceed.
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv2("cur?ouser", "curiouser", FIND) |
| 2 | TRUE | =MATCHES.SOMEHOWv2("cur?ouser", "curiouser", SEARCH) |
| 3 | #NAME? | =MATCHES.SOMEHOWv2("cur?ouser", "curiouser", RAND) |
Exercise: Come up with a set of arguments to deterministically prove whether the function passed in is either
ROUND,MROUND, orTRUNC. You want one set of arguments that can be uniformly applied to each expected function and resolve to a unique result.
Now, with 100% more FindText
Doing a simple substring search isn’t very interesting. We’ll make it more interesting by searching for multiple strings:
MATCHES.SOMEHOWv3 = LAMBDA(FindTextArray, WithinText, TextFunction,
LET(
IsValidTextFunction?, LET(
_isFind, IFERROR(
TextFunction("*ef", "abc*ef") = 4,
FALSE
),
_isSearch, IFERROR(
TextFunction("*ef", "abc*ef") = 1,
FALSE
),
OR(_isFind, _isSearch)
),
ExecuteFunction, LAMBDA(
LET(
_result, MAP(
FindTextArray,
LAMBDA(_findText,
ISNUMBER(TextFunction(_findText, WithinText))
)
),
OR(_result)
)
),
IF(IsValidTextFunction?, ExecuteFunction(), #NAME?)
)
);
Now we’re doing something more useful; performing multiple finds at once and returning true if any of the substrings are found. You’ll notice that I wrapped up the actual execution of TextFunction with an inline lambda. Looking at deeply nested if-statements makes me sad; YMMV.
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv3({"not here","cur?ouser"}, "curiouser", FIND) |
| 2 | TRUE | =MATCHES.SOMEHOWv3({"not here","cur?ouser"}, "curiouser", SEARCH) |
| 3 | #NAME? | =MATCHES.SOMEHOWv3({"not here","cur?ouser"}, "curiouser", RAND) |
Towards a more logical solution
I know what you’re thinking. This technique is cool and all, but we already have FIND and SEARCH. Maybe you enjoy wrapping up multiple FIND/SEARCH calls with OR or AND. Why on Earth should we reinvent the wheel?
Because it’s fun to discover new things.
I promised optional boolean predicates — I will deliver optional boolean predicates. But before we do that, let’s get rid of the TextFunction argument altogether. We’ve established that functions can be passed around, and we’ll still do that internally, but we can simplify the user experience by merely exposing a UseWildcards parameter.
The new function is then:
MATCHES.SOMEHOWv4 = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
LET(
UseWildcards, IF(
ISOMITTED(UseWildcards),
FALSE,
UseWildcards
),
TextFunction, IF(UseWildcards, SEARCH, FIND),
_result, MAP(
FindTextArray,
LAMBDA(_findText,
ISNUMBER(TextFunction(_findText, WithinText))
)
),
OR(_result)
)
);
We’re still passing function like a Real Language™, just not forcing anyone else to deal with it or even understand it.
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv4({"not here","cur?ouser"}, "curiouser") |
| 2 | TRUE | =MATCHES.SOMEHOWv4({"not here","cur?ouser"}, "curiouser", TRUE) |
The more logical solution
We’ll add an optional [LogicalOperator] argument and have it default to OR. Theoretically, we should be able to pass AND, OR and XOR to mean “matches all”, “matches any”, and “matches exactly one”.
MATCHES.SOMEHOWv5 = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[LogicalOperation],
LET(
LogicalOperation, IF(
ISOMITTED(LogicalOperation),
OR,
LogicalOperation
),
UseWildcards, IF(
ISOMITTED(UseWildcards),
FALSE,
UseWildcards
),
TextFunction, IF(UseWildcards, SEARCH, FIND),
_result, MAP(
FindTextArray,
LAMBDA(_findText,
ISNUMBER(TextFunction(_findText, WithinText))
)
),
LogicalOperation(_result)
)
);
And the results are:
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv5({"not here","cur?ouser"}, "curiouser") |
| 2 | TRUE | =MATCHES.SOMEHOWv5({"not here","cur?ouser"}, "curiouser", TRUE) |
| 3 | FALSE | =MATCHES.SOMEHOWv5({"user","cur?ouser"}, "curiouser",,AND) |
| 4 | TRUE | =MATCHES.SOMEHOWv5({"user","cur?ouser"}, "curiouser", TRUE, AND) |
| 5 | FALSE | =MATCHES.SOMEHOWv5({"user","cur?ouser"}, "curiouser", TRUE, XOR) |
| 6 | TRUE | =MATCHES.SOMEHOWv5({"curio","user","cur?ouser"}, "curiouser", TRUE, XOR) |
Big trouble in little formula
You’ll note that XOR manages to fail in both directions, which is impressive in the worst possible way. In the two‑item case, it should light up only when exactly one substring matches — but XOR returns FALSE even though one does. When we add a third finder it swings the other way, returning TRUE even though two matches should disqualify it. The culprit is simple: XOR isn’t checking for “exactly one TRUE”, it’s doing strict odd‑parity math. Two matches is even, so it shuts off; three is odd, so it lights up. In other words, Excel is doing algebra when we really need it to do English.
Let’s see if we can’t whip it into shape with more behavioral probing. If we know the boolean truth tables for the logical operators we want to support (and we do!), we can construct a bitmask and discover at evaluation time which operator was passed. If it’s AND or OR, we can let it through as is. If it’s XOR, we can intercept it and convince it to act right.
Before we can move onward to MATCHES.SOMEHOWv6, we’ll need a function to determine the signature of [LogicalOperator]. We need a bitmask.
LOGICAL.SIG = LAMBDA(LogicalOperation,
LET(
_ff, --LogicalOperation(FALSE, FALSE),
_ft, --LogicalOperation(FALSE, TRUE),
_tf, --LogicalOperation(TRUE, FALSE),
_tt, --LogicalOperation(TRUE, TRUE),
_ff * 8 + _ft * 4 + _tf * 2 + _tt * 1
)
);
| Operator | FF × 8 | FT × 4 | TF × 2 | TT × 1 | Signature |
|---|---|---|---|---|---|
| AND | 0 × 8 = 0 | 0 × 4 = 0 | 0 × 2 = 0 | 1 × 1 = 1 | 0 + 0 + 0 + 1 = 1 |
| OR | 0 × 8 = 0 | 1 × 4 = 4 | 1 × 2 = 2 | 1 × 1 = 1 | 0 + 4 + 2 + 1 = 7 |
| XOR | 0 × 8 = 0 | 1 × 4 = 4 | 1 × 2 = 2 | 0 × 1 = 0 | 0 + 4 + 2 + 0 = 6 |
Now that we can reliably determine which [LogicalOperator] we received, and act accordingly, behold the MATCHES.SOMEHOWv6 function in all its temporary glory:
MATCHES.SOMEHOWv6 = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[LogicalOperation],
LET(
LogicalOperation, IF(
ISOMITTED(LogicalOperation),
OR,
LogicalOperation
),
UseWildcards, IF(
ISOMITTED(UseWildcards),
FALSE,
UseWildcards
),
TextFunction, IF(UseWildcards, SEARCH, FIND),
_logicalSignature, LOGICAL.SIG(LogicalOperation),
_result, MAP(
FindTextArray,
LAMBDA(_findText,
ISNUMBER(TextFunction(_findText, WithinText))
)
),
IF(
_logicalSignature = 6,
SUM(--_result) = 1,
LogicalOperation(_result)
)
)
);
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv6({"user","cur?ouser"}, "curiouser", TRUE, XOR) |
| 2 | FALSE | =MATCHES.SOMEHOWv6({"curio","user","cur?ouser"}, "curiouser", TRUE, XOR) |
| 3 | TRUE | =MATCHES.SOMEHOWv6({"nope","cur?ouser"}, "curiouser", TRUE, XOR) |
| 4 | TRUE | =MATCHES.SOMEHOWv6({"nope","never","cur?ouser"}, "curiouser", TRUE, XOR) |
A NOR promised, a NOR received
“A gem cannot be polished without friction,
NORan Excel function be perfected without trials.“- Seneca, probably
Now we have a good foundation for building upon, let’s tackle the other boolean predicate I promised: NOR. Excel has no built in NOR function that I am aware of, so let’s build one:
NOR = LAMBDA(FirstOperandOrArray, [SecondOperand],
LET(
_canHaveSecondOperand?, (
ROWS(FirstOperandOrArray) *
COLUMNS(FirstOperandOrArray)
) = 1,
_hasSecondOperand?, NOT(ISOMITTED(SecondOperand)),
IFS(
AND(_canHaveSecondOperand?, _hasSecondOperand?),
NOT(OR(FirstOperandOrArray, SecondOperand)),
AND(
NOT(_canHaveSecondOperand?),
NOT(_hasSecondOperand?)
),
NOT(OR(FirstOperandOrArray)),
TRUE,
NA()
)
)
);
Because our LOGICAL.SIG() function requires a logical operator taking two arguments, we give our NOR the opportunity to accept either an array of boolean operands, or two boolean operands. If both an array and a second operand are provided, or a single operand alone, we’ll return #N/A.
This new logical operator will slot right into our MATCHES.SOMEHOWv6() function as-is. Here are the results:
| A | B | |
|---|---|---|
| 1 | FALSE | =MATCHES.SOMEHOWv6({"user","cur?ouser"}, "curiouser", TRUE, NOR) |
| 2 | FALSE | =MATCHES.SOMEHOWv6({"curio","user","cur?ouser"}, "curiouser", TRUE, NOR) |
| 3 | FALSE | =MATCHES.SOMEHOWv6({"nope","cur?ouser"}, "curiouser", TRUE, NOR) |
| 4 | TRUE | =MATCHES.SOMEHOWv6({"nope","never","ever"}, "curiouser", TRUE, NOR) |
Now before we even reach our final form, let’s add one more nice touch to the basic function, and rename it to something meaningful; MATCHES.SOMEHOWv99() isn’t a great look. Let’s call it MATCHES.MULTIPLE() and add a [StartPosition] argument to complete the delegation to FIND and SEARCH.
MATCHES.MULTIPLE = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
[LogicalOperation],
LET(
StartPosition, IF(
ISOMITTED(StartPosition),
1,
StartPosition
),
LogicalOperation, IF(
ISOMITTED(LogicalOperation),
OR,
LogicalOperation
),
UseWildcards, IF(
ISOMITTED(UseWildcards),
FALSE,
UseWildcards
),
TextFunction, IF(UseWildcards, SEARCH, FIND),
_logicalSignature, LOGICAL.SIG(LogicalOperation),
_result, MAP(
FindTextArray,
LAMBDA(_findText,
ISNUMBER(
TextFunction(
_findText,
WithinText,
StartPosition
)
)
)
),
IF(
_logicalSignature = 6,
SUM(--_result) = 1,
LogicalOperation(_result)
)
)
);
The [StartPosition] argument gets forwarded to TextFunction. If it’s omitted, then TextFunction gets called with the default value of 1. Unfortunately for us, you cannot simply forward the omitted argument to TextFunction without some kind of default. It doesn’t really like that and will silently start returning false-negatives again.
Wrappering it up
Recall what I said earlier about passing functions like a Real Language™ while not making the end user deal with it. We should package these functions into something accessible to the average Excel user. You shouldn’t need to be a serious programmer or have a degree in calculus to simply put number in box.
Matches all substrings: LIKE.ALL()
LIKE.ALL = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
MATCHES.MULTIPLE(
FindTextArray,
WithinText,
UseWildcards,
StartPosition,
AND
)
);
| A | B | |
|---|---|---|
| 1 | The quick brown fox jumped over the two lazy dogs. | |
| 2 | TRUE | =LIKE.ALL({"quick","fox","lazy","dogs"},$A$1) |
| 3 | FALSE | =LIKE.ALL({"quick","fox","lazy","cats"},$A$1) |
| 4 | TRUE | =LIKE.ALL({"quick","f?x","la*","dogs"},$A$1,TRUE) |
| 5 | FALSE | =LIKE.ALL({"quick","f?x","la*","cats"},$A$1,TRUE) |
| 6 | TRUE | =LIKE.ALL({"quick","f?x","la*","dogs"},$A$1,TRUE,4) |
| 7 | FALSE | =LIKE.ALL({"quick","f?x","la*","cats"},$A$1,TRUE,6) |
From here on out, I’ll be omitting every combination of arguments for the sake of brevity. You’ve been through enough.
Matches any substring: LIKE.ANY()
LIKE.ANY = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
MATCHES.MULTIPLE(
FindTextArray,
WithinText,
UseWildcards,
StartPosition,
OR
)
);
| A | B | |
|---|---|---|
| 1 | The quick brown fox jumped over the two lazy dogs. | |
| 2 | TRUE | =LIKE.ANY({"slow", "tu?tle", "exc*", "dogs"}, $A$1, TRUE) |
| 3 | FALSE | =LIKE.ANY({"slow", "tu?tle", "exc*", "cats"}, $A$1, TRUE) |
Matches no substrings: LIKE.NONE()
LIKE.NONE = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
MATCHES.MULTIPLE(
FindTextArray,
WithinText,
UseWildcards,
StartPosition,
NOR
)
);
| A | B | |
|---|---|---|
| 1 | The quick brown fox jumped over the two lazy dogs. | |
| 2 | TRUE | =LIKE.NONE({"slow", "tu?tle", "exc*", "cats"}, $A$1, TRUE) |
| 3 | FALSE | =LIKE.NONE({"slow", "tu?tle", "exc*", "dogs"}, $A$1, TRUE) |
Matches exactly one substring: LIKE.ONLY_ONE()
LIKE.ONLY_ONE = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
MATCHES.MULTIPLE(
FindTextArray,
WithinText,
UseWildcards,
StartPosition,
XOR
)
);
| A | B | |
|---|---|---|
| 1 | The quick brown fox jumped over the two lazy dogs. | |
| 2 | TRUE | =LIKE.ONLY_ONE({"slow", "tu?tle", "exc*", "dogs"}, $A$1, TRUE) |
| 3 | FALSE | =LIKE.ONLY_ONE({"slow", "tu?tle", "laz*", "dogs"}, $A$1, TRUE) |
BONUS ROUND: NAND and XNOR
This didn’t feel complete so here are the two extra logical operations I alluded to at the start. For our purposes here, NAND means “not all”, and XNOR means “not only one”. The utility of these additions — or let’s be honest, this entire article — is debatable. But I’m a fan of completeness. To implement these, we’ll to define our two additional logical operator functions:
NAND = LAMBDA(FirstOperandOrArray, [SecondOperand],
LET(
_canHaveSecondOperand?, (
ROWS(FirstOperandOrArray) *
COLUMNS(FirstOperandOrArray)
) = 1,
_hasSecondOperand?, NOT(ISOMITTED(SecondOperand)),
IFS(
AND(_canHaveSecondOperand?, _hasSecondOperand?),
NOT(AND(FirstOperandOrArray, SecondOperand)),
AND(
NOT(_canHaveSecondOperand?),
NOT(_hasSecondOperand?)
),
NOT(AND(FirstOperandOrArray)),
TRUE,
NA()
)
)
);
XNOR = LAMBDA(FirstOperandOrArray, [SecondOperand],
LET(
_canHaveSecondOperand?, (
ROWS(FirstOperandOrArray) *
COLUMNS(FirstOperandOrArray)
) = 1,
_hasSecondOperand?, NOT(ISOMITTED(SecondOperand)),
IFS(
AND(_canHaveSecondOperand?, _hasSecondOperand?),
NOT(XOR(FirstOperandOrArray, SecondOperand)),
AND(
NOT(_canHaveSecondOperand?),
NOT(_hasSecondOperand?)
),
SUM(--FirstOperandOrArray) <> 1,
TRUE,
NA()
)
)
);
Matches some, but not all, substrings: LIKE.NOT_ALL()
LIKE.NOT_ALL = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
MATCHES.MULTIPLE(
FindTextArray,
WithinText,
UseWildcards,
StartPosition,
NAND
)
);
| A | B | |
|---|---|---|
| 1 | The quick brown fox jumped over the two lazy dogs. | |
| 2 | TRUE | =LIKE.NOT_ALL({"quick", "f?x", "la*", "cats"}, $A$1, TRUE) |
| 3 | FALSE | =LIKE.NOT_ALL({"quick", "f?x", "la*", "dogs"}, $A$1, TRUE) |
Matches some, or none, but not only one substring: LIKE.NOT_ONLY_ONE()
LIKE.NOT_ONLY_ONE = LAMBDA(
FindTextArray,
WithinText,
[UseWildcards],
[StartPosition],
MATCHES.MULTIPLE(
FindTextArray,
WithinText,
UseWildcards,
StartPosition,
XNOR
)
);
This one deserves three examples to illustrate the interesting nature of XNOR in this capacity. “Not only one” necessarily includes “zero” as a passing scenario.
| A | B | |
|---|---|---|
| 1 | The quick brown fox jumped over the two lazy dogs. | |
| 2 | TRUE | =LIKE.NOT_ONLY_ONE({"slow", "ha?r", "silly", "cats"}, $A$1, TRUE) |
| 3 | FALSE | =LIKE.NOT_ONLY_ONE({"quick", "ha?r", "silly", "cats"}, $A$1, TRUE) |
| 4 | TRUE | =LIKE.NOT_ONLY_ONE({"quick", "ha?r", "silly", "dogs"}, $A$1, TRUE) |
Final thoughts
We started by taking eta reduction at its word: building a function up, only to tear it down again, that it might stand on its own.
From that modest bit of punctuation removal, we:
- passed behavior through Excel
- identified behavior by its signature
- corrected
XOR’s odd-parity shenanigans - and built a small family of useful search functions on top.
Excel still does not have first-class functions in the way a Real Language™ does, but it has enough of the machinery to let us make something practical — and enough undocumented weirdness to keep the whole exercise interesting. The functions work, the wrappers are thin, and the logical operators have been beaten into submission.
We’re done.
Coda
You’ve all been wonderful. No really, the pleasure has been all mine. Normally I hate addressing people I don’t know, but it’s like you aren’t even here!
Now get out there and MAKE TOOL.