Repository: remeda/remeda
Pull request #777 (2024-07-11 12:43:48)

feat(when): add the when utility
A simplified version of the `conditional` function that provides better typing, and with better ergonomics: it doesn't require an array wrapper, it has an implicit default case that makes sense for a lot of use-cases, and it passes arguments through.

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `docs/src/content/mapping` for both Lodash and Ramda.

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions, and changes to a function's type that would impact users.
- `fix`: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/remeda/remeda/pr/777/builds/521976) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit b582e3120a26684d135bc90b7ea561b485e13a68:

|Sandbox| Source |
|--|--|
|[remeda-example-vanilla](https://codesandbox.io/s/remeda-example-vanilla-8qcdzr)| Configuration |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/777?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`72773b6`)](https://app.codecov.io/gh/remeda/remeda/commit/72773b67c724a0552cba2914226860fb9ca2afb7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`3da4703`)](https://app.codecov.io/gh/remeda/remeda/commit/3da470310cd1e15cdcce946b6c02456738b88c92?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              main      #777    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          292       294     +2     
  Lines         9946     10158   +212     
  Branches      2427      2472    +45     
==========================================
+ Hits          9946     10158   +212     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/777?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

eranhirsch: Hey, thanks for your review and questions, they make sense, and I'll try to convey my thought process around this:

I wrote this utility in one of my projects where I needed to fix something about an object I was constructing, but only in certain cases. I wanted a way to run code conditionally, but only in some cases, and the ergo of conditional (and the typing issues) weren't a good fit.

```ts
const result = pipe(
  ...
  branch(
    // This is a type-predicate
    (x) => x.mode === "double",
    (x) => ({ ...x, data: [...x.dataPartA, ...x.dataPartB] }),
  ),
  ...
);
```

This isn't easily solvable with our typing of `conditional`, so I had to build a new utility.

2. While building this, I looked for an equivalent in Lodash and Ramda and came across ifElse. It made sense to add an else clause because the implementation was already using the ternary operator, which supports it. This way, we provide a de facto wrapper around the ternary for use in pipes, which is cool.

3. But I didn't want to always call it with an else clause because sometimes you just don't need one, and having a bunch of `identity()` calls just makes the code less readable and confusing. So, I opted for an optional argument instead.

4. The variadic args feature came by while writing the tests; I wanted to show a cool usage using the map to fill in gaps in an array, but I wanted to use the `index` param to create unique items. [Ramda's ifElse supports this too](https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAKjgQwM5wEpwGZQiOAIimRABNlCBuAKBoGMIA7VebCCOAXkwDphsAUQA2qAKYAKGnDgB6WXAB6AfmlwJADwBccVlGBMA5gBoUOgEYdhY5E1PmLVm3bj1HEa7YCU3AHxqZDW4uHkIACzFhYQhCOAAyOJR4xPNk12M1CR8uXzgAZgyZLL84ABYMr1ogA).

5. But it doesn't make sense in a data-first call as there's only one invocation, so the arguments come from the same place and could be used via closure instead of pass-through. I wanted the function to have the same signature for both data-first and data-last, so I added it to the data-first. Still, the ambiguity with the optional param means we either need to make more heuristic choices in the currying function or *drop support*. I'm fine with doing any of the 3 options in this regard: 1. dropping it from both data-first signatures, adding it to both, and having some more heuristics in the currying function, or 3. keeping the current impl.

6. `defaultTo` is a completely different thing; it only takes a **value** and not a *mapping function*. With the availability of `constant` and `branch`, this function isn't required as it's easy to compose. In other words, it's a subset of functionality that is only good for a specific case and could be composed using other more general functions, so following similar decisions in the past (e.g. `compact`), it shouldn't be included in the lib.
eranhirsch: Another way to solve the issues you raised that we can consider is creating a hybrid type for the mapping function that would either be a single function (for the no-else path) or an **object** with two mapper functions (for the with-else path):

```ts
branch(data, predicate, onTrue, ...args);
branch(data, predicate, { onTrue, onFalse }, ...args);
branch(predicate, onTrue): (data, ...args) => 
branch(predicate, { onTrue, onFalse })): (data, ...args) =>
```

The typing is a little harder, but not by much, and we can use `purry` and skip the heuristic problems.
cjquines: hey, some responses:

agreed with points 1 and 2

> 3. But I didn't want to always call it with an else clause because sometimes you don't need one, and having a bunch of `identity()` calls makes the code less readable and confusing. So, I opted for an optional argument instead. We can drop the optionality of `onElse`, but I think there are a lot of use cases where it isn't needed (defaultTo, my example above, etc...) that become more cumbersome when it's required.

hm, my thought is that in this case it should be named a different thing. having one `branch` function for both is elegant, but i find it hard to read. in my head i see `branch`, `onTrue`, what happens when it's false? oh, it's not there, so it passes through. i think there could be a better name in this case, mostly _because_ there are a lot of use cases where it isn't needed, and in this case seeing `branch` doesn't quite make sense to me?

> 6. But it doesn't make sense in a data-first call as there's only one invocation, so the arguments come from the same place and could be used via closure instead of pass-through. I wanted the function to have the same signature for both data-first and data-last, so I added it to the data-first. Still, the ambiguity with the optional param means we either need to make more heuristic choices in the currying function or _drop support_. I'm okay with doing any of the three options: 1. dropping it from both data-first signatures, adding it to both, and having some more heuristics in the currying function, or 3. keeping the current impl.

it's true that if we don't support this now and add this later, then it'll be a breaking change. i'm still not sure how i feel about the ergonomics of this, but this is a smaller thought than the one i have under 3

> 7. `defaultTo` is a completely different thing; it only takes a **value** and not a _mapping function_. With the availability of `constant` and `branch`, this function isn't required as it's easy to compose. In other words, it's a subset of functionality that is only good for a specific case and could be composed using other more general functions, so following similar decisions in the past (e.g., `compact`), it shouldn't be included in the lib.

so we didn't include `compact` because it's composable, but we've included other functions that are easily written for the sake of readability, like `add`. in this case i would also make an argument for readability, which is the same argument i have under 3
eranhirsch: I've sent a new version with my suggestion to use an object to label the if and else paths when both are used. This addresses your concern about ambiguity and makes the currying simpler and more correct by not excluding functions as data.

A simple `if` statement and an `if-else` construct are both considered branches from a CS perspective, so I would consider both of them equally valid semantically for a function called branch; I don't see the value in splitting this into two functions with almost identical code.

I feel that the example with `add` is not symmetric to the example with `compact`. Compact would have the same implementation as a filter; so it forces us to handle laziness and testing although it is nearly identical `filter` already. `add`, on the other hand, doesn't have an equivalent function. It's not complex to write a function that does it, but that's not the point. the better example would be if someone wanted to add an `increment` function when we already have `add`; `add` is more valuable than `increment` because it is more flexible, there are more use-cases where it's valuable, as-is, and `increment` on the other hand, could be done using `add(1)`.
cjquines: i like the idea about the `onTrue` and `onFalse` case, i think it helps. i still have some the same thoughts about the case without `onFalse`. sorry if i'm being too tough about this, but let me try to explain? when i read

```ts
map(
  data,
  branch(
    (x) => x === undefined,
    (_, index) => `item_${index}`,
  ),
);
```

my instinct is that `branch` takes two arguments, that the data is being branched into the `(x) => x === undefined` and the `` (_, index) => `item_${index}` `` case. but then i have to realize that, no, this is actually an `if` statement, the first one is a condition, the second one is the output, and there's a third, implicit output, if the condition is false.

compare something like

```ts
map(
  data,
  changeIf(
    (x) => x === undefined,
    (_, index) => `item_${index}`,
  ),
);
```

maybe it'd help if we got feedback from outside the two of us?

also, re

> splitting this into two functions with almost identical code

i dont actually think the code is almost identical, the purrying would be much simpler for example
eranhirsch: Don't worry about making extra rounds of reviewing. I don't mind it and think it results in a better-quality product.

I understand your concern about the naming and readability, and the name `branch` was just something I came up with because I didn't like `ifElse` for obvious reasons 😉 and couldn't use `if`. Looking at Lodash and Ramda also makes it clear that `branch` isn't a name they thought suitable either. One alterantive that I have in mind is calling it `when` instead, so that:

```ts
map(data, when(isString, (x) => parseInt(x)));
map(data, isNullish, (_, index) => `item_${index}`);

const result = pipe(
  ...
  when(isSomthing, {
    onTrue: doSomthing,
    onFalse: doSomethingElse,
  }),
  ...
);

myApi({ onFoo: when(isError, handleError, handleSuccess) });
```

It looks like Ramda uses that name similarly (and I missed it when adding the mapping docs): https://ramdajs.com/docs/#when

Otherwise, it would be helpful for the discussion so that we can open this to a broader audience to help decide if you can come up with your own suggestions for what changes you see would suffice to approve this PR. It would make it easier to see both options side-by-side when making the call.
eranhirsch: @cjquines - Do you have any objections to the current name? I need this utility in a project of mine but am reluctant to ship it without a review.
cjquines: i started a discussion in https://github.com/remeda/remeda/discussions/824
eranhirsch: @cjquines, let's get this PR shipped

Patch:
diff --git a/docs/src/content/mapping/lodash/__MISSING.md b/docs/src/content/mapping/lodash/__MISSING.md
index b7697d1..9a4c21e 100644
--- a/docs/src/content/mapping/lodash/__MISSING.md
+++ b/docs/src/content/mapping/lodash/__MISSING.md
@@ -156,7 +156,6 @@ TODO: Go over: https://you-dont-need.github.io/You-Dont-Need-Lodash-Underscore/#
 - attempt
 - bindAll
 - conforms
-- defaultTo
 - flowRight
 - iteratee
 - matches
diff --git a/docs/src/content/mapping/lodash/defaultTo.md b/docs/src/content/mapping/lodash/defaultTo.md
new file mode 100644
index 0000000..6e5adba
--- /dev/null
+++ b/docs/src/content/mapping/lodash/defaultTo.md
@@ -0,0 +1,44 @@
+---
+category: Util
+remeda: when
+---
+
+- Use Remeda's [`when`] with [`isNullish`](/docs#isNullish) as the predicate
+  and the fallback value wrapped with [`constant`](/docs#constant).
+- For defaulting `NaN` values use the built-in [`Number.isNaN`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN)
+  instead.
+- For both you'd need to construct a type-guard manually.
+
+### Nullish
+
+```ts
+// Lodash
+defaultTo(DATA, 10);
+
+// Remeda
+when(DATA, isNullish, constant(10));
+```
+
+### NaN
+
+```ts
+// Lodash
+defaultTo(DATA, 10);
+
+// Remeda
+when(DATA, Number.isNaN, constant(10));
+```
+
+### Both
+
+```ts
+// Lodash
+defaultTo(DATA, 10);
+
+// Remeda
+when(
+  DATA,
+  (x) => x === undefined || x === null || Number.isNaN(x),
+  constant(10),
+);
+```
diff --git a/docs/src/content/mapping/ramda/__MISSING.md b/docs/src/content/mapping/ramda/__MISSING.md
index c1c1ed6..6c85119 100644
--- a/docs/src/content/mapping/ramda/__MISSING.md
+++ b/docs/src/content/mapping/ramda/__MISSING.md
@@ -96,16 +96,13 @@
 - and
 - both
 - complement
-- defaultTo
 - either
-- ifElse
 - isNotEmpty
 - or
 - pathSatisfies
 - propSatisfies
 - unless
 - until
-- when
 - xor
 
 # Math
diff --git a/docs/src/content/mapping/ramda/defaultTo.md b/docs/src/content/mapping/ramda/defaultTo.md
new file mode 100644
index 0000000..aaf25b7
--- /dev/null
+++ b/docs/src/content/mapping/ramda/defaultTo.md
@@ -0,0 +1,44 @@
+---
+category: Logic
+remeda: when
+---
+
+- Use Remeda's [`when`] with [`isNullish`](/docs#isNullish) as the predicate
+  and the fallback value wrapped with [`constant`](/docs#constant).
+- For defaulting `NaN` values use the built-in [`Number.isNaN`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN)
+  instead.
+- For both you'd need to construct a type-guard manually.
+
+### Nullish
+
+```ts
+// Ramda
+defaultTo(DATA, 10);
+
+// Remeda
+when(DATA, isNullish, constant(10));
+```
+
+### NaN
+
+```ts
+// Ramda
+defaultTo(DATA, 10);
+
+// Remeda
+when(DATA, Number.isNaN, constant(10));
+```
+
+### Both
+
+```ts
+// Ramda
+defaultTo(DATA, 10);
+
+// Remeda
+when(
+  DATA,
+  (x) => x === undefined || x === null || Number.isNaN(x),
+  constant(10),
+);
+```
diff --git a/docs/src/content/mapping/ramda/ifElse.md b/docs/src/content/mapping/ramda/ifElse.md
new file mode 100644
index 0000000..a972b36
--- /dev/null
+++ b/docs/src/content/mapping/ramda/ifElse.md
@@ -0,0 +1,58 @@
+---
+category: Logic
+remeda: when
+---
+
+- Remeda supports both if-like statements, and if-else-like statements (which is
+  what Ramda's ifElse function supports); The former is done by using a
+  predicate function and a mapper function, and the latter is done by wrapping
+  both mappers with an object.
+- To support extra arguments Ramda requires one of the functions provided to
+  provide full typing to all params, in Remeda this isn't needed as the extra
+  arguments would be inferred from the call site.
+
+### if-else
+
+```ts
+// Ramda
+ifElse(predicate, onTrue, onFalse)(data);
+
+// Remeda
+when(data, predicate, { onTrue, onFalse });
+
+// Or in a pipe
+pipe(data, when(predicate, { onTrue, onFalse }));
+```
+
+### if
+
+```ts
+ifElse(predicate, onTrue, identity)(data);
+
+// Remeda
+when(data, predicate, onTrue);
+
+// Or in a pipe
+pipe(data, when(predicate, onTrue));
+```
+
+### extra args
+
+```ts
+// Ramda
+const mapper = ifElse(
+  (x: string | undefined, index: number) => x === undefined,
+  (_, index) => `item_${index}`,
+  identity,
+);
+map(data, mapper);
+
+// Remeda
+map(
+  data,
+  when(
+    (x) => x === undefined,
+    (_, index) => `item_${index}`,
+  ),
+);
+```
diff --git a/docs/src/content/mapping/ramda/when.md b/docs/src/content/mapping/ramda/when.md
new file mode 100644
index 0000000..1c7bb7d
--- /dev/null
+++ b/docs/src/content/mapping/ramda/when.md
@@ -0,0 +1,4 @@
+---
+category: Logic
+remeda: when
+---
diff --git a/src/conditional.ts b/src/conditional.ts
index d2423d9..6790c6e 100644
--- a/src/conditional.ts
+++ b/src/conditional.ts
@@ -21,6 +21,8 @@ export { conditionalPlus as conditional };
  * corresponding transformer, and returns, ignoring any further cases, even if
  * they would match.
  *
+ * For simpler cases you should also consider using `when` instead.
+ *
  * !IMPORTANT! - Unlike similar implementations in frameworks like Lodash and
  * Ramda, the Remeda implementation does **NOT** return a default/fallback
  * `undefined` value when none of the cases match; and instead will **throw** an
@@ -112,6 +114,8 @@ function conditional<
  * corresponding transformer, and returns, ignoring any further cases, even if
  * they would match.
  *
+ * For simpler cases you should also consider using `when` instead.
+ *
  * !IMPORTANT! - Unlike similar implementations in frameworks like Lodash and
  * Ramda, the Remeda implementation does **NOT** return a default/fallback
  * `undefined` value when none of the cases match; and instead will **throw** an
diff --git a/src/index.ts b/src/index.ts
index a10be31..60ed80d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -144,5 +144,6 @@ export * from "./unique";
 export * from "./uniqueBy";
 export * from "./uniqueWith";
 export * from "./values";
+export * from "./when";
 export * from "./zip";
 export * from "./zipWith";
diff --git a/src/when.ts b/src/when.ts
new file mode 100644
index 0000000..48f3320
--- /dev/null
+++ b/src/when.ts
@@ -0,0 +1,173 @@
+/* eslint-disable @typescript-eslint/no-explicit-any --
+ * function inference is stricter and doesn't work well when the arguments
+ * aren't typed as `any` in the generic type declaration.
+ */
+
+import type { GuardType } from "./internal/types";
+
+/**
+ * Conditionally run a function based on a predicate, returning it's result (similar to
+ * the [`?:` (ternary) operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator).)
+ * If the optional `onFalse` function is not provided, the data will be passed
+ * through in those cases.
+ *
+ * Supports type predicates to refine the types for both branches and the return
+ * value.
+ *
+ * Additional arguments are passed to all functions. In data-first calls, they
+ * are taken as variadic arguments; but in data-last calls, they are when the
+ * curried function itself is called.
+ *
+ * For more complex cases check out `conditional`.
+ *
+ * @param predicate - Decides if the `onTrue` mapper should run or not. If it's
+ * a type predicate it also narrows types for the mappers and the return value.
+ * @param onTrue - Function to run when the predicate returns `true`.
+ * @signature
+ *   when(predicate, onTrue)(data, ...extraArgs)
+ *   when(predicate, { onTrue, onFalse })(data, ...extraArgs)
+ * @example
+ *   pipe(data, when(isNullish, constant(42)));
+ *   pipe(data, when((x) => x > 3, { onTrue: add(1), onFalse: multiply(2) }));
+ *   map(data, when(isNullish, (x, index) => x + index));
+ * @dataLast
+ * @category Function
+ */
+export function when<
+  T,
+  ExtraArgs extends Array<any>,
+  Predicate extends (data: T, ...extraArgs: ExtraArgs) => boolean,
+  OnTrue extends (
+    data: GuardType<Predicate, T>,
+    ...extraArgs: ExtraArgs
+  ) => unknown,
+>(
+  predicate: Predicate,
+  onTrue: OnTrue,
+): (
+  data: T,
+  ...extraArgs: ExtraArgs
+) => Exclude<T, GuardType<Predicate>> | ReturnType<OnTrue>;
+export function when<
+  T,
+  ExtraArgs extends Array<any>,
+  Predicate extends (data: T, ...extraArgs: ExtraArgs) => boolean,
+  OnTrue extends (
+    data: GuardType<Predicate, T>,
+    ...extraArgs: ExtraArgs
+  ) => unknown,
+  OnFalse extends (
+    data: Exclude<T, GuardType<Predicate>>,
+    ...extraArgs: ExtraArgs
+  ) => unknown,
+>(
+  predicate: Predicate,
+  branches: {
+    readonly onTrue: OnTrue;
+    readonly onFalse: OnFalse;
+  },
+): (
+  data: T,
+  ...extraArgs: ExtraArgs
+) => ReturnType<OnFalse> | ReturnType<OnTrue>;
+
+/**
+ * Conditionally run a function based on a predicate, returning it's result (similar to
+ * the [`?:` (ternary) operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator).)
+ * If the optional `onFalse` function is not provided, the data will be passed
+ * through in those cases.
+ *
+ * Supports type predicates to refine the types for both branches and the return
+ * value.
+ *
+ * Additional arguments are passed to all functions. In data-first calls, they
+ * are taken as variadic arguments; but in data-last calls, they are when the
+ * curried function itself is called.
+ *
+ * For more complex cases check out `conditional`.
+ *
+ * @param data - The data to be passed to all functions, as the first param.
+ * @param predicate - Decides if the `onTrue` mapper should run or not. If it's
+ * a type predicate it also narrows types for the mappers and the return value.
+ * @param onTrue - The function that would run when the predicate returns
+ * `true`.
+ * @param extraArgs - Additional arguments. These would be passed as is to the
+ * `predicate`, `onTrue`, and `onFalse` functions.
+ * @signature
+ *   when(data, predicate, onTrue, ...extraArgs)
+ *   when(data, predicate, { onTrue, onFalse }, ...extraArgs)
+ * @example
+ *   when(data, isNullish, constant(42));
+ *   when(data, (x) => x > 3, { onTrue: add(1), onFalse: multiply(2) });
+ *   when(data, isString, (x, radix) => parseInt(x, radix), 10);
+ * @dataFirst
+ * @category Function
+ */
+export function when<
+  T,
+  ExtraArgs extends Array<any>,
+  Predicate extends (data: T, ...extraArgs: ExtraArgs) => boolean,
+  OnTrue extends (
+    data: GuardType<Predicate, T>,
+    ...extraArgs: ExtraArgs
+  ) => unknown,
+>(
+  data: T,
+  predicate: Predicate,
+  onTrue: OnTrue,
+  ...extraArgs: ExtraArgs
+): Exclude<T, GuardType<Predicate>> | ReturnType<OnTrue>;
+export function when<
+  T,
+  ExtraArgs extends Array<any>,
+  Predicate extends (data: T, ...extraArgs: ExtraArgs) => boolean,
+  OnTrue extends (
+    data: GuardType<Predicate, T>,
+    ...extraArgs: ExtraArgs
+  ) => unknown,
+  OnFalse extends (
+    data: Exclude<T, GuardType<Predicate>>,
+    ...extraArgs: ExtraArgs
+  ) => unknown,
+>(
+  data: T,
+  predicate: Predicate,
+  branches: {
+    readonly onTrue: OnTrue;
+    readonly onFalse: OnFalse;
+  },
+  ...extraArgs: ExtraArgs
+): ReturnType<OnFalse> | ReturnType<OnTrue>;
+
+export function when(...args: ReadonlyArray<unknown>): unknown {
+  return args.length === 2
+    ? (data: unknown, ...extraArgs: ReadonlyArray<unknown>) =>
+        // @ts-expect-error [ts2556] -- This is OK, we trust our typing of the overloaded functions
+        whenImplementation(data, ...args, ...extraArgs)
+    : // @ts-expect-error [ts2556] -- This is OK, we trust our typing of the overloaded functions
+      whenImplementation(...args);
+}
+
+const whenImplementation = <
+  T,
+  ExtraArgs extends Array<any>,
+  WhenTrue,
+  WhenFalse,
+>(
+  data: T,
+  predicate: (data: T, ...extraArgs: ExtraArgs) => boolean,
+  onTrueOrBranches:
+    | ((data: T, ...extraArgs: ExtraArgs) => WhenTrue)
+    | {
+        readonly onTrue: (data: T, ...extraArgs: ExtraArgs) => WhenTrue;
+        readonly onFalse: (data: T, ...extraArgs: ExtraArgs) => WhenFalse;
+      },
+  ...extraArgs: ExtraArgs
+): T | WhenFalse | WhenTrue =>
+  predicate(data, ...extraArgs)
+    ? typeof onTrueOrBranches === "function"
+      ? onTrueOrBranches(data, ...extraArgs)
+      : onTrueOrBranches.onTrue(data, ...extraArgs)
+    : typeof onTrueOrBranches === "function"
+      ? data
+      : onTrueOrBranches.onFalse(data, ...extraArgs);


Test patch:
diff --git a/src/when.test-d.ts b/src/when.test-d.ts
new file mode 100644
index 0000000..a6be811
--- /dev/null
+++ b/src/when.test-d.ts
@@ -0,0 +1,449 @@
+import { constant } from "./constant";
+import { isString } from "./isString";
+import { map } from "./map";
+import { pipe } from "./pipe";
+import { when } from "./when";
+
+describe("dataFirst", () => {
+  describe("no else", () => {
+    describe("simple predicates", () => {
+      test("mapper param is not narrowed", () => {
+        const data = "hello" as number | string;
+        when(data, constant(true), (x) => {
+          expectTypeOf(x).toEqualTypeOf(data);
+        });
+      });
+
+      it("return type is not narrowed ", () => {
+        const data = "hello" as number | string;
+        const result = when(data, constant(true), constant({ a: 1 }));
+        // The result contains both the input type, and the result of the
+        // branch.
+        expectTypeOf(result).toEqualTypeOf<typeof data | { a: number }>();
+      });
+
+      it("passes extra args to the functions", () => {
+        const data = "hello" as number | string;
+        when(
+          data,
+          constant(true),
+          (x, a, b, c) => {
+            expectTypeOf(x).toEqualTypeOf(data);
+            expectTypeOf(a).toEqualTypeOf<1>();
+            expectTypeOf(b).toEqualTypeOf<"cat">();
+            expectTypeOf(c).toEqualTypeOf<{ readonly a: 123 }>();
+          },
+          // Extra args:
+          1 as const,
+          "cat" as const,
+          { a: 123 } as const,
+        );
+      });
+    });
+
+    describe("type-guards", () => {
+      it("narrows the mapper's param", () => {
+        const data = "hello" as number | string;
+        when(data, isString, (x) => {
+          expectTypeOf(x).toEqualTypeOf<string>();
+        });
+      });
+
+      it("removes narrowed types from the output", () => {
+        const data = "hello" as number | string;
+        const result = when(data, isString, constant("cat" as const));
+        // The result doesn't contain the input type that was narrowed against
+        // (string), but does contain the input type that wasn't (number).
+        expectTypeOf(result).toEqualTypeOf<number | "cat">();
+      });
+
+      it("passes extra args to the functions (workaround)", () => {
+        const data = "hello" as number | string;
+        when(
+          data,
+          isString,
+          (x, a, b, c) => {
+            expectTypeOf(x).toEqualTypeOf<string>();
+            expectTypeOf(a).toEqualTypeOf<1>();
+            expectTypeOf(b).toEqualTypeOf<"cat">();
+            expectTypeOf(c).toEqualTypeOf<{ readonly a: 123 }>();
+          },
+          // Extra args:
+          1 as const,
+          "cat" as const,
+          { a: 123 } as const,
+        );
+      });
+    });
+  });
+
+  describe("with else", () => {
+    describe("simple predicates", () => {
+      test("mapper's param is not narrowed", () => {
+        const data = "hello" as number | string;
+        when(data, constant(true), {
+          onTrue: (x) => {
+            expectTypeOf(x).toEqualTypeOf(data);
+          },
+          onFalse: (x) => {
+            expectTypeOf(x).toEqualTypeOf(data);
+          },
+        });
+      });
+
+      it("returns the union of the branch return types", () => {
+        const data = "hello" as number | string;
+        const result = when(data, constant(true), {
+          onTrue: constant("cat" as const),
+          onFalse: constant("dog" as const),
+        });
+        expectTypeOf(result).toEqualTypeOf<"cat" | "dog">();
+      });
+
+      it("passes extra args to the functions (workaround)", () => {
+        const data = "hello" as number | string;
+        when(
+          data,
+          constant(true),
+          {
+            onTrue: (x, a, b, c) => {
+              expectTypeOf(x).toEqualTypeOf(data);
+              expectTypeOf(a).toEqualTypeOf<1>();
+              expectTypeOf(b).toEqualTypeOf<"cat">();
+              expectTypeOf(c).toEqualTypeOf<{ readonly a: 123 }>();
+            },
+            onFalse: (x, a, b, c) => {
+              expectTypeOf(x).toEqualTypeOf(data);
+              expectTypeOf(a).toEqualTypeOf<1>();
+              expectTypeOf(b).toEqualTypeOf<"cat">();
+              expectTypeOf(c).toEqualTypeOf<{ readonly a: 123 }>();
+            },
+          },
+          // Extra args:
+          1 as const,
+          "cat" as const,
+          { a: 123 } as const,
+        );
+      });
+    });
+
+    describe("type-guards", () => {
+      it("narrows the mapper's param", () => {
+        const data = "hello" as number | string;
+        when(data, isString, {
+          onTrue: (x) => {
+            // Narrowed
+            expectTypeOf(x).toEqualTypeOf<string>();
+          },
+          onFalse: (x) => {
+            // Also narrowed!
+            expectTypeOf(x).toEqualTypeOf<number>();
+          },
+        });
+      });
+
+      it("returns the union of the branch return types", () => {
+        const data = "hello" as number | string;
+        const result = when(data, isString, {
+          onTrue: constant("cat" as const),
+          onFalse: constant("dog" as const),
+        });
+        expectTypeOf(result).toEqualTypeOf<"cat" | "dog">();
+      });
+
+      it("passes extra args to the functions (workaround)", () => {
+        const data = "hello" as number | string;
+        when(
+          data,
+          isString,
+          {
+            onTrue: (x, a, b, c) => {
+              // Narrowed
+              expectTypeOf(x).toEqualTypeOf<string>();
+              expectTypeOf(a).toEqualTypeOf<1>();
+              expectTypeOf(b).toEqualTypeOf<"cat">();
+              expectTypeOf(c).toEqualTypeOf<{ readonly a: 123 }>();
+            },
+            onFalse: (x, a, b, c) => {
+              // Also narrowed!
+              expectTypeOf(x).toEqualTypeOf<number>();
+              expectTypeOf(a).toEqualTypeOf<1>();
+              expectTypeOf(b).toEqualTypeOf<"cat">();
+              expectTypeOf(c).toEqualTypeOf<{ readonly a: 123 }>();
+            },
+          },
+          // Extra args:
+          1 as const,
+          "cat" as const,
+          { a: 123 } as const,
+        );
+      });
+    });
+  });
+});
+
+describe("dataLast", () => {
+  describe("no else", () => {
+    describe("simple predicates", () => {
+      test("mapper param is not narrowed", () => {
+        const data = "hello" as number | string;
+        pipe(
+          data,
+          when(constant(true), (x) => {
+            expectTypeOf(x).toEqualTypeOf(data);
+          }),
+        );
+      });
+
+      it("return type is not narrowed ", () => {
+        const data = "hello" as number | string;
+        const result = pipe(data, when(constant(true), constant({ a: 1 })));
+        // The result contains both the input type, and the result of the
+        // branch.
+        expectTypeOf(result).toEqualTypeOf<typeof data | { a: number }>();
+      });
+
+      it("passes extra args to the functions", () => {
+        const data = [] as Array<number | string>;
+        map(
+          data,
+          when(constant(true), (x, index, array) => {
+            expectTypeOf(x).toEqualTypeOf<number | string>();
+            expectTypeOf(index).toEqualTypeOf<number>();
+            expectTypeOf(array).toEqualTypeOf<typeof data>();
+          }),
+        );
+      });
+    });
+
+    describe("type-guards", () => {
+      it("narrows the mapper's param", () => {
+        const data = "hello" as number | string;
+        pipe(
+          data,
+          when(isString, (x) => {
+            expectTypeOf(x).toEqualTypeOf<string>();
+          }),
+        );
+      });
+
+      it("removes narrowed types from the output", () => {
+        const data = "hello" as number | string;
+        const result = pipe(data, when(isString, constant("cat" as const)));
+        // The result doesn't contain the input type that was narrowed against
+        // (string), but does contain the input type that wasn't (number).
+        expectTypeOf(result).toEqualTypeOf<number | "cat">();
+      });
+    });
+
+    it("passes extra args to the functions", () => {
+      const data = [] as Array<number | string>;
+      map(
+        data,
+        when(isString, (x, index, array) => {
+          expectTypeOf(x).toEqualTypeOf<string>();
+          expectTypeOf(index).toEqualTypeOf<number>();
+          expectTypeOf(array).toEqualTypeOf<typeof data>();
+        }),
+      );
+    });
+  });
+
+  describe("with else", () => {
+    describe("simple predicates", () => {
+      test("mapper's param is not narrowed", () => {
+        const data = "hello" as number | string;
+        pipe(
+          data,
+          when(constant(true), {
+            onTrue: (x) => {
+              expectTypeOf(x).toEqualTypeOf(data);
+            },
+            onFalse: (x) => {
+              expectTypeOf(x).toEqualTypeOf(data);
+            },
+          }),
+        );
+      });
+
+      it("returns the union of the branch return types", () => {
+        const data = "hello" as number | string;
+        const result = pipe(
+          data,
+          when(constant(true), {
+            onTrue: constant("cat" as const),
+            onFalse: constant("dog" as const),
+          }),
+        );
+        expectTypeOf(result).toEqualTypeOf<"cat" | "dog">();
+      });
+
+      it("passes extra args to the functions", () => {
+        const data = [] as Array<number | string>;
+        map(
+          data,
+          when(constant(true), {
+            onTrue: (x, index, array) => {
+              expectTypeOf(x).toEqualTypeOf<number | string>();
+              expectTypeOf(index).toEqualTypeOf<number>();
+              expectTypeOf(array).toEqualTypeOf<typeof data>();
+            },
+            onFalse: (x, index, array) => {
+              expectTypeOf(x).toEqualTypeOf<number | string>();
+              expectTypeOf(index).toEqualTypeOf<number>();
+              expectTypeOf(array).toEqualTypeOf<typeof data>();
+            },
+          }),
+        );
+      });
+    });
+
+    describe("type-guards", () => {
+      it("narrows the mapper's param", () => {
+        const data = "hello" as number | string;
+        pipe(
+          data,
+          when(isString, {
+            onTrue: (x) => {
+              // Narrowed
+              expectTypeOf(x).toEqualTypeOf<string>();
+            },
+            onFalse: (x) => {
+              // Also narrowed!
+              expectTypeOf(x).toEqualTypeOf<number>();
+            },
+          }),
+        );
+      });
+
+      it("returns the union of the branch return types", () => {
+        const data = "hello" as number | string;
+        const result = pipe(
+          data,
+          when(isString, {
+            onTrue: constant("cat" as const),
+            onFalse: constant("dog" as const),
+          }),
+        );
+        expectTypeOf(result).toEqualTypeOf<"cat" | "dog">();
+      });
+
+      it("passes extra args to the functions", () => {
+        const data = [] as Array<number | string>;
+        map(
+          data,
+          when(isString, {
+            onTrue: (x, index, array) => {
+              // Narrowed!
+              expectTypeOf(x).toEqualTypeOf<string>();
+              expectTypeOf(index).toEqualTypeOf<number>();
+              expectTypeOf(array).toEqualTypeOf<typeof data>();
+            },
+            onFalse: (x, index, array) => {
+              // Also narrowed!
+              expectTypeOf(x).toEqualTypeOf<number>();
+              expectTypeOf(index).toEqualTypeOf<number>();
+              expectTypeOf(array).toEqualTypeOf<typeof data>();
+            },
+          }),
+        );
+      });
+    });
+  });
+});
+
+describe("typing mismatches", () => {
+  test("Predicate enforces data param when explicitly stated", () => {
+    // @ts-expect-error [ts2769] -- The predicate can't take an undefined value
+    when(1 as number | undefined, (x: number) => x > 3, constant(3));
+  });
+
+  test("Mappers enforce data param when explicitly stated", () => {
+    // @ts-expect-error [ts2769] -- The mapper can't take an undefined value
+    when(1 as number | undefined, constant(true), (x: number) => x + 1);
+  });
+
+  test("Type of extra args enforced (data-first)", () => {
+    when(
+      1,
+      constant(true),
+      // @ts-expect-error [ts2345] -- The extra arg is not a boolean
+      (x: number, _: boolean) => x,
+      (x: number, _: boolean) => x,
+      "hello",
+    );
+  });
+
+  test("Number of extra args enforced (data-first)", () => {
+    when(
+      1,
+      constant(true),
+      // @ts-expect-error [ts2345] -- Not enough extra args
+      (x: number, _a: boolean, _b: boolean, _c: boolean) => x,
+      (x: number, _a: boolean, _b: boolean, _c: boolean) => x,
+      true,
+      false,
+    );
+  });
+
+  test("Type of extra args enforced (data-last)", () => {
+    map(
+      [] as Array<string>,
+      when(
+        constant(true),
+        // @ts-expect-error [ts2345] -- The extra arg is not a boolean
+        (x: string, _: boolean) => x,
+      ),
+    );
+  });
+
+  test("Number of extra args enforced (data-last)", () => {
+    map(
+      [] as Array<string>,
+      when(
+        constant(true),
+        // @ts-expect-error [ts2345] -- Not enough extra args
+        (
+          x: string,
+          _index: number,
+          _data: ReadonlyArray<string>,
+          _isSomething: boolean,
+        ) => x,
+      ),
+    );
+  });
+});
+
+describe("recipes", () => {
+  it("handles api response union response objects", () => {
+    someApi({
+      onFoo: when(isErrorPayload, {
+        onTrue: ({ error }) => handleError(error),
+        onFalse: ({ data }) => handleSuccess(data),
+      }),
+    });
+  });
+});
+
+// Some API that returns a union of objects for different response states
+declare function someApi(options: {
+  readonly onFoo: (payload: Payload) => void;
+}): void;
+type Payload =
+  | ErrorPayload
+  | {
+      readonly status: "ok";
+      readonly data: string;
+    };
+type ErrorPayload = {
+  readonly status: "error";
+  readonly error: Error;
+};
+
+// A pivoting function, in TypeScript 5.5 these might be inferred for a lot of
+// cases and could be inlined.
+declare function isErrorPayload(payload: Payload): payload is ErrorPayload;
+
+// Our handlers for the different states
+declare function handleError(error: Error): void;
+declare function handleSuccess(data: string): void;
diff --git a/src/when.test.ts b/src/when.test.ts
new file mode 100644
index 0000000..ee78516
--- /dev/null
+++ b/src/when.test.ts
@@ -0,0 +1,181 @@
+import { constant } from "./constant";
+import { isDefined } from "./isDefined";
+import { isNot } from "./isNot";
+import { isNullish } from "./isNullish";
+import { isStrictEqual } from "./isStrictEqual";
+import { isString } from "./isString";
+import { map } from "./map";
+import { pipe } from "./pipe";
+import { when } from "./when";
+
+describe("dataFirst", () => {
+  describe("without else", () => {
+    it("returns the happy path when true", () => {
+      expect(when("hello", isStrictEqual("hello"), constant("was true"))).toBe(
+        "was true",
+      );
+    });
+
+    it("returns the identity when false", () => {
+      expect(when("hello", isStrictEqual("olleh"), constant("was true"))).toBe(
+        "hello",
+      );
+    });
+
+    it("passes extra args to the functions", () => {
+      expect(
+        when(
+          "20",
+          isString,
+          (x, radix) => Number.parseInt(x, radix),
+          // Extra args:
+          16 /* radix */,
+        ),
+      ).toBe(32);
+    });
+  });
+
+  describe("with else", () => {
+    it("returns the happy path when true", () => {
+      expect(when("hello", isStrictEqual("hello"), constant("was true"))).toBe(
+        "was true",
+      );
+    });
+
+    it("returns the else path when false", () => {
+      expect(
+        when("hello", isStrictEqual("olleh"), {
+          onTrue: constant("was true"),
+          onFalse: constant("was false"),
+        }),
+      ).toBe("was false");
+    });
+
+    it("passes extra args to the functions", () => {
+      expect(
+        when(
+          123,
+          (x) => x % 2 === 0,
+          {
+            onTrue: (x, offset) => x + offset,
+            onFalse: (x, offset) => x + offset + 1,
+          },
+          // Extra args:
+          100,
+        ),
+      ).toBe(224);
+    });
+  });
+});
+
+describe("dataLast", () => {
+  describe("without else", () => {
+    it("returns the happy path when true", () => {
+      expect(
+        pipe("hello", when(isStrictEqual("hello"), constant("was true"))),
+      ).toBe("was true");
+    });
+
+    it("returns the identity when false", () => {
+      expect(
+        pipe("hello", when(isStrictEqual("olleh"), constant("was true"))),
+      ).toBe("hello");
+    });
+
+    it("passes extra args to the functions", () => {
+      expect(
+        map(
+          [1, 2, 3, 4],
+          when(
+            (x) => x % 2 === 0,
+            (x, index) => x + index,
+          ),
+        ),
+      ).toStrictEqual([1, 3, 3, 7]);
+    });
+  });
+
+  describe("with else", () => {
+    it("returns the happy path when true", () => {
+      expect(
+        pipe("hello", when(isStrictEqual("hello"), constant("was true"))),
+      ).toBe("was true");
+    });
+
+    it("returns the else path when false", () => {
+      expect(
+        pipe(
+          "hello",
+          when(isStrictEqual("olleh"), {
+            onTrue: constant("was true"),
+            onFalse: constant("was false"),
+          }),
+        ),
+      ).toBe("was false");
+    });
+  });
+
+  it("passes extra args to the functions", () => {
+    expect(
+      map(
+        [1, 2, 3, 4],
+        when((x) => x % 2 === 0, {
+          onTrue: (x, index) => x + index,
+          onFalse: (x, index) => `${x}${index}`,
+        }),
+      ),
+    ).toStrictEqual(["10", 3, "32", 7]);
+  });
+});
+
+it("can return other types", () => {
+  expect(
+    when(
+      "hello",
+      isStrictEqual("hello"),
+      constant(42),
+      constant({ a: "hello" }),
+    ),
+  ).toBe(42);
+
+  expect(
+    when("hello", isStrictEqual("olleh"), {
+      onTrue: constant(42),
+      onFalse: constant({ a: "hello" }),
+    }),
+  ).toStrictEqual({ a: "hello" });
+});
+
+describe("recipes", () => {
+  it("acts as a coalesce tool", () => {
+    expect(
+      map(
+        [0, 1, 2, undefined, 4, undefined, 6],
+        when(isNot(isDefined), constant("missing")),
+      ),
+    ).toStrictEqual([0, 1, 2, "missing", 4, "missing", 6]);
+  });
+
+  it("can replace defaultTo", () => {
+    // Nullish
+    expect(
+      map([undefined, null, 1], when(isNullish, constant(42))),
+    ).toStrictEqual([42, 42, 1]);
+
+    // NaN
+    expect(
+      map([Number.NaN, 1], when(Number.isNaN, constant(42))),
+    ).toStrictEqual([42, 1]);
+
+    // All
+    expect(
+      map(
+        [Number.NaN, undefined, null, 1],
+        when(
+          (x) => Number.isNaN(x) || x === null || x === undefined,
+          constant(42),
+        ),
+      ),
+    ).toStrictEqual([42, 42, 42, 1]);
+  });
+});