Repository: remeda/remeda
Pull request #331 (2023-05-31 06:25:00)

feat: add conditional
I added the cond function present in Lodash and Ramda. I found myself reaching for it the other day in a React project and thought I'd add it.

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/331/builds/383028) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 794e42fa59957eaa356127a8796f0e58e96fc096:

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

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/331?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`ba72cbb`)](https://app.codecov.io/gh/remeda/remeda/commit/ba72cbb7490a687047da7bdef42715378790370f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 99.86% compared to head [(`794e42f`)](https://app.codecov.io/gh/remeda/remeda/pull/331?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 99.86%.

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


```diff
@@           Coverage Diff           @@
##           master     #331   +/-   ##
=======================================
  Coverage   99.86%   99.86%           
=======================================
  Files          99      100    +1     
  Lines        5802     5853   +51     
  Branches      562      567    +5     
=======================================
+ Hits         5794     5845   +51     
  Misses          8        8           
```


| [Impacted Files](https://app.codecov.io/gh/remeda/remeda/pull/331?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) | Coverage Δ | |
|---|---|---|
| [src/cond.ts](https://app.codecov.io/gh/remeda/remeda/pull/331?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda#diff-c3JjL2NvbmQudHM=) | `100.00% <100.00%> (ø)` | |
| [src/sample.ts](https://app.codecov.io/gh/remeda/remeda/pull/331?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda#diff-c3JjL3NhbXBsZS50cw==) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/331?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

TkDodo: There's also an open issue mentioning `cond`:

- https://github.com/remeda/remeda/issues/214
eranhirsch: @thelawclaw11 - Are you planning on continuing work on this?
thelawclaw11: > @thelawclaw11 - Are you planning on continuing work on this?

Hey sorry I've been unresponsive. Yes I'm planning to work on this. Work has been crazy but I should have some free time this weekend or next.
cjquines: `cond` is a traditional name: for example both lodash and ramda use it; i think it comes from [lisp](https://www.cs.cmu.edu/Groups/AI/html/cltl/clm/node84.html).

of course, there is no need to follow tradition here
eranhirsch: @thelawclaw11, I continued your work because I wanted to ship this. Could you take a look and see if it meets your requirements?

Patch:
diff --git a/mapping.md b/mapping.md
index 52535c1..5814432 100644
--- a/mapping.md
+++ b/mapping.md
@@ -17,6 +17,7 @@ documentation when migrating._
 | [`clamp`](https://remedajs.com/docs/#clamp)                         | [`clamp`](https://lodash.com/docs/4.17.15#clamp)                         | [`clamp`](https://ramdajs.com/docs/#clamp)                         |
 | [`clone`](https://remedajs.com/docs/#clone)                         | [`cloneDeep`](https://lodash.com/docs/4.17.15#cloneDeep)                 | [`clone`](https://ramdajs.com/docs/#clone)                         |
 | [`concat`](https://remedajs.com/docs/#concat)                       | [`concat`](https://lodash.com/docs/4.17.15#concat)                       | [`concat`](https://ramdajs.com/docs/#concat)                       |
+| [`conditional`](https://remedajs.com/docs/#conditional)             | [`cond`](https://lodash.com/docs/4.17.15#cond)                           | [`cond`](https://ramdajs.com/docs/#cond)                           |
 | [`createPipe`](https://remedajs.com/docs/#createPipe)               |                                                                          |                                                                    |
 | [`debounce`](https://remedajs.com/docs/#debounce)                   | [`debounce`](https://lodash.com/docs/4.17.15#debounce)                   |                                                                    |
 | [`difference`](https://remedajs.com/docs/#difference)               | [`difference`](https://lodash.com/docs/4.17.15#difference)               | [`difference`](https://ramdajs.com/docs/#difference)               |
diff --git a/src/_purryOn.ts b/src/_purryOn.ts
new file mode 100644
index 0000000..f744cc4
--- /dev/null
+++ b/src/_purryOn.ts
@@ -0,0 +1,16 @@
+/**
+ * Utility for purrying functions based on a predicate for the first argument.
+ *
+ * This is useful for purrying functions with a variadic argument list.
+ */
+export function purryOn<T>(
+  isArg: (firstArg: unknown) => firstArg is T,
+  implementation: (firstArg: T, ...args: Array<any>) => unknown,
+  args: IArguments
+): unknown {
+  const callArgs = Array.from(args);
+  const [dataOrArg, ...rest] = callArgs;
+  return isArg(dataOrArg)
+    ? (data: T) => implementation(data, ...callArgs)
+    : implementation(dataOrArg, ...rest);
+}
diff --git a/src/conditional.ts b/src/conditional.ts
new file mode 100644
index 0000000..24ef6cb
--- /dev/null
+++ b/src/conditional.ts
@@ -0,0 +1,237 @@
+import { purryOn } from './_purryOn';
+
+type Case<In, Out, Thru extends In = In> = readonly [
+  when: ((data: In) => data is Thru) | ((data: In) => boolean),
+  then: (data: Thru) => Out,
+];
+
+/**
+ * Executes a transformer function based on the first matching predicate,
+ * functioning like a series of `if...else if...` statements. It sequentially
+ * evaluates each case and, upon finding a truthy predicate, runs the
+ * corresponding transformer, and returns, ignoring any further cases, even if
+ * they would match.
+ *
+ * !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
+ * exception in those cases.
+ * To add a default case use the `conditional.defaultCase` helper as the final
+ * case of your implementation. By default it returns `undefined`, but could be
+ * provided a transformer in order to return something else.
+ *
+ * Due to TypeScript's inability to infer the result of negating a type-
+ * predicate we can't refine the types used in subsequent cases based on
+ * previous conditions. Using a `switch (true)` statement or ternary operators
+ * is recommended for more precise type control when such type narrowing is
+ * needed.
+ *
+ * @param data - The input data to be evaluated against the provided cases.
+ * @param cases - A list of (up to 10) tuples, each defining a case. Each tuple
+ * consists of a predicate (or a type-predicate) and a transformer function that
+ * processes the data if its case matches.
+ * @returns The output of the matched transformer. If no cases match, an
+ * exception is thrown. The return type is a union of the return types of all
+ * provided transformers.
+ * @signature
+ *   R.conditional(...cases)(data);
+ * @example
+ *   const nameOrId = 3 as string | number;
+ *   R.pipe(
+ *     nameOrId,
+ *     R.conditional(
+ *       [R.isString, (name) => `Hello ${name}`],
+ *       [R.isNumber, (id) => `Hello ID: ${id}`],
+ *       R.conditional.defaultCase(
+ *         (something) => `Hello something (${JSON.stringify(something)})`,
+ *       ),
+ *     ),
+ *   ); //=> 'Hello ID: 3'
+ * @dataLast
+ * @category Function
+ */
+export function conditional<
+  T,
+  Return0,
+  Return1 = never,
+  Return2 = never,
+  Return3 = never,
+  Return4 = never,
+  Return5 = never,
+  Return6 = never,
+  Return7 = never,
+  Return8 = never,
+  Return9 = never,
+  Thru0 extends T = T,
+  Thru1 extends T = T,
+  Thru2 extends T = T,
+  Thru3 extends T = T,
+  Thru4 extends T = T,
+  Thru5 extends T = T,
+  Thru6 extends T = T,
+  Thru7 extends T = T,
+  Thru8 extends T = T,
+  Thru9 extends T = T,
+>(
+  case0: Case<T, Return0, Thru0>,
+  case1?: Case<T, Return1, Thru1>,
+  case2?: Case<T, Return2, Thru2>,
+  case3?: Case<T, Return3, Thru3>,
+  case4?: Case<T, Return4, Thru4>,
+  case5?: Case<T, Return5, Thru5>,
+  case6?: Case<T, Return6, Thru6>,
+  case7?: Case<T, Return7, Thru7>,
+  case8?: Case<T, Return8, Thru8>,
+  case9?: Case<T, Return9, Thru9>
+): (
+  data: T
+) =>
+  | Return0
+  | Return1
+  | Return2
+  | Return3
+  | Return4
+  | Return5
+  | Return6
+  | Return7
+  | Return8
+  | Return9;
+
+/**
+ * Executes a transformer function based on the first matching predicate,
+ * functioning like a series of `if...else if...` statements. It sequentially
+ * evaluates each case and, upon finding a truthy predicate, runs the
+ * corresponding transformer, and returns, ignoring any further cases, even if
+ * they would match.
+ *
+ * !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
+ * exception in those cases.
+ * To add a default case use the `conditional.defaultCase` helper as the final
+ * case of your implementation. By default it returns `undefined`, but could be
+ * provided a transformer in order to return something else.
+ *
+ * Due to TypeScript's inability to infer the result of negating a type-
+ * predicate we can't refine the types used in subsequent cases based on
+ * previous conditions. Using a `switch (true)` statement or ternary operators
+ * is recommended for more precise type control when such type narrowing is
+ * needed.
+ *
+ * @param data - The input data to be evaluated against the provided cases.
+ * @param cases - A list of (up to 10) tuples, each defining a case. Each tuple
+ * consists of a predicate (or a type-predicate) and a transformer function that
+ * processes the data if its case matches.
+ * @returns The output of the matched transformer. If no cases match, an
+ * exception is thrown. The return type is a union of the return types of all
+ * provided transformers.
+ * @signature
+ *   R.conditional(data, ...cases);
+ * @example
+ *   const nameOrId = 3 as string | number;
+ *   R.conditional(
+ *     nameOrId,
+ *     [R.isString, (name) => `Hello ${name}`],
+ *     [R.isNumber, (id) => `Hello ID: ${id}`],
+ *     R.conditional.defaultCase(
+ *       (something) => `Hello something (${JSON.stringify(something)})`,
+ *     ),
+ *   ); //=> 'Hello ID: 3'
+ * @dataFirst
+ * @category Function
+ */
+export function conditional<
+  T,
+  Return0,
+  Return1 = never,
+  Return2 = never,
+  Return3 = never,
+  Return4 = never,
+  Return5 = never,
+  Return6 = never,
+  Return7 = never,
+  Return8 = never,
+  Return9 = never,
+  Thru0 extends T = T,
+  Thru1 extends T = T,
+  Thru2 extends T = T,
+  Thru3 extends T = T,
+  Thru4 extends T = T,
+  Thru5 extends T = T,
+  Thru6 extends T = T,
+  Thru7 extends T = T,
+  Thru8 extends T = T,
+  Thru9 extends T = T,
+>(
+  data: T,
+  case0: Case<T, Return0, Thru0>,
+  case1?: Case<T, Return1, Thru1>,
+  case2?: Case<T, Return2, Thru2>,
+  case3?: Case<T, Return3, Thru3>,
+  case4?: Case<T, Return4, Thru4>,
+  case5?: Case<T, Return5, Thru5>,
+  case6?: Case<T, Return6, Thru6>,
+  case7?: Case<T, Return7, Thru7>,
+  case8?: Case<T, Return8, Thru8>,
+  case9?: Case<T, Return9, Thru9>
+):
+  | Return0
+  | Return1
+  | Return2
+  | Return3
+  | Return4
+  | Return5
+  | Return6
+  | Return7
+  | Return8
+  | Return9;
+
+export function conditional(): unknown {
+  return purryOn(isCase, conditionalImplementation, arguments);
+}
+
+function conditionalImplementation<In, Out>(
+  data: In,
+  ...cases: ReadonlyArray<Case<In, Out>>
+): Out {
+  for (const [when, then] of cases) {
+    if (when(data)) {
+      return then(data);
+    }
+  }
+
+  throw new Error('conditional: data failed for all cases');
+}
+
+function isCase(maybeCase: unknown): maybeCase is Case<unknown, unknown> {
+  if (!Array.isArray(maybeCase)) {
+    return false;
+  }
+
+  const [when, then, ...rest] = maybeCase;
+  return (
+    typeof when === 'function' &&
+    when.length <= 1 &&
+    typeof then === 'function' &&
+    then.length <= 1 &&
+    rest.length === 0
+  );
+}
+
+export namespace conditional {
+  /**
+   * A simplified case that accepts all data. Put this as the last case to
+   * prevent an exception from being thrown when none of the previous cases
+   * match.
+   * If this is not the last case it will short-circuit anything after it.
+   * @param then - You only need to provide the transformer, the predicate is
+   * implicit. @default () => undefined, which is how Lodash and Ramda handle
+   * the final fallback case.
+   */
+  export const defaultCase = <In>(
+    then: (data: In) => unknown = trivialDefaultCase
+  ) => [() => true, then] as const;
+}
+
+// Lodash and Ramda return `undefined` as the default case.
+const trivialDefaultCase = () => undefined;
diff --git a/src/index.ts b/src/index.ts
index 1fc4569..ea3453f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,7 @@ export * from './clamp';
 export * from './clone';
 export * from './compact';
 export * from './concat';
+export * from './conditional';
 export * from './countBy';
 export * from './createPipe';
 export * from './debounce';


Test patch:
diff --git a/src/conditional.test.ts b/src/conditional.test.ts
new file mode 100644
index 0000000..cbc5c64
--- /dev/null
+++ b/src/conditional.test.ts
@@ -0,0 +1,91 @@
+import { conditional } from './conditional';
+import { equals } from './equals';
+import { isNumber } from './isNumber';
+import { isString } from './isString';
+import { pipe } from './pipe';
+
+describe('runtime (dataFirst)', () => {
+  it('falls back to trivial default', () => {
+    expect(conditional('Jokic', conditional.defaultCase())).toBeUndefined();
+  });
+
+  it('falls back to our default', () => {
+    expect(
+      conditional(
+        'Jokic',
+        conditional.defaultCase(() => 'hello')
+      )
+    ).toEqual('hello');
+  });
+
+  it('works with a single case', () => {
+    expect(conditional('Jokic', [equals('Jokic'), () => 'center'])).toBe(
+      'center'
+    );
+  });
+
+  it('works with two cases', () => {
+    expect(
+      conditional(
+        'Jokic',
+        [equals('Murray'), () => 'point guard'],
+        [equals('Jokic'), () => 'center']
+      )
+    ).toBe('center');
+  });
+
+  it('picks the first matching case', () => {
+    expect(
+      conditional(
+        'Jokic',
+        [equals('Jokic'), () => 'center'],
+        [equals('Jokic'), () => 'mvp']
+      )
+    ).toBe('center');
+  });
+
+  it('throws when no matching case', () => {
+    expect(() =>
+      conditional('Jokic', [() => false, () => 'world'])
+    ).toThrowErrorMatchingInlineSnapshot(
+      `[Error: conditional: data failed for all cases]`
+    );
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  test('should return value of first pair', () => {
+    const value = pipe(
+      'Jokic',
+      conditional(
+        [equals('Murray'), () => 'point guard'],
+        [equals('Jokic'), () => 'center'],
+        [equals('Jokic'), () => 'mvp']
+      )
+    );
+    expect(value).toBe('center');
+  });
+});
+
+describe('typing', () => {
+  it('can narrow types in the transformers', () => {
+    const data = 3 as string | number;
+    conditional(
+      data,
+      [
+        isString,
+        str => {
+          expectTypeOf(str).toBeString();
+          expectTypeOf(str).not.toBeNumber();
+        },
+      ],
+      [
+        isNumber,
+        num => {
+          expectTypeOf(num).toBeNumber();
+          expectTypeOf(num).not.toBeString();
+        },
+      ]
+    );
+  });
+});