Repository: remeda/remeda
Pull request #909 (2024-11-03 14:20:18)

feat: add `toKebabCase` and `toSnakeCase`
By popular demand, adding simplified kebab case and snake case functions.

The return types are trivial (`string`) because type-fest doesn't export `SplitWords` and because its own KebabCase and SnakeCase types create unions of possibilities instead of a concrete single outcome. #818 tries to deal with that issue more concretely and can replace this implementation once the changes in type-fest are approved.

I'm still deliberating with myself whether these functions need to be part of the library or that we should export `splitWords` instead, as they are simple compositions on top of it (with `join` and `toLowerCase`). My issue with split words is that it isn't very smart; it's a very mechanical way to define what a word is and not very linguistic. By exporting it, we take responsibility for fixing all sorts of issues people have with it when they use it for linguistic purposes. On the other hand, the utilities that use it (snake_case, camelCase, kebab-case) are mechanical themselves, and I don't think people will be as surprised if they don't make sense linguistically. 

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

Latest deployment of this branch, based on commit a1b38c78680ba02df1d409b7c9672ef34eaf53c9:

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

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/909?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 [(`300682c`)](https://app.codecov.io/gh/remeda/remeda/commit/300682c6cee29e9a80ab04a645a8ea0035c1ca4e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`a1b38c7`)](https://app.codecov.io/gh/remeda/remeda/commit/a1b38c78680ba02df1d409b7c9672ef34eaf53c9?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      #909   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          323       325    +2     
  Lines        10884     10966   +82     
  Branches      2746      2770   +24     
=========================================
+ Hits         10884     10966   +82     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/909?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: I didn't notice that SplitWords isn't exported by type-fest :(
eranhirsch: Better types waiting for https://github.com/sindresorhus/type-fest/pull/975, but we shouldn't wait; we will release a fix later if they approve it.

Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..8d937c8
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,1 @@
+github: remeda
diff --git a/docs/src/content/mapping/lodash/__MISSING.md b/docs/src/content/mapping/lodash/__MISSING.md
index 08f2234..b679d97 100644
--- a/docs/src/content/mapping/lodash/__MISSING.md
+++ b/docs/src/content/mapping/lodash/__MISSING.md
@@ -129,7 +129,6 @@ TODO: Go over: https://you-dont-need.github.io/You-Dont-Need-Lodash-Underscore/#
 - deburr
 - escape
 - escapeRegExp
-- kebabCase
 - lowerCase
 - pad
 - padEnd
@@ -137,7 +136,6 @@ TODO: Go over: https://you-dont-need.github.io/You-Dont-Need-Lodash-Underscore/#
 - parseInt
 - repeat
 - replace
-- snakeCase
 - startCase
 - template
 - trim
diff --git a/docs/src/content/mapping/lodash/kebabCase.md b/docs/src/content/mapping/lodash/kebabCase.md
new file mode 100644
index 0000000..c7c573b
--- /dev/null
+++ b/docs/src/content/mapping/lodash/kebabCase.md
@@ -0,0 +1,7 @@
+---
+category: String
+remeda: toKebabCase
+---
+
+The remeda implementation doesn't perform deburring (changing of accented latin
+characters to simple LATIN-1 characters), and doesn't remove apostrophes.
diff --git a/docs/src/content/mapping/lodash/snakeCase.md b/docs/src/content/mapping/lodash/snakeCase.md
new file mode 100644
index 0000000..4bf0a13
--- /dev/null
+++ b/docs/src/content/mapping/lodash/snakeCase.md
@@ -0,0 +1,7 @@
+---
+category: String
+remeda: toSnakeCase
+---
+
+The remeda implementation doesn't perform deburring (changing of accented latin
+characters to simple LATIN-1 characters), and doesn't remove apostrophes.
diff --git a/src/capitalize.ts b/src/capitalize.ts
index 4ce9b04..eaca6d9 100644
--- a/src/capitalize.ts
+++ b/src/capitalize.ts
@@ -7,7 +7,7 @@ import { purry } from "./purry";
  * utility type for typing.
  *
  * For other case manipulations see: `toUpperCase`, `toLowerCase`,
- * `uncapitalize`, and `toCamelCase`.
+ * `uncapitalize`, `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  * If the output is intended for display (on a browser) consider using
@@ -31,7 +31,7 @@ export function capitalize<T extends string>(data: T): Capitalize<T>;
  * utility type for typing.
  *
  * For other case manipulations see: `toUpperCase`, `toLowerCase`,
- * `uncapitalize`, and `toCamelCase`.
+ * `uncapitalize`, `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  * If the output is intended for display (on a browser) consider using
diff --git a/src/index.ts b/src/index.ts
index 3663fa4..5eddc2f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -141,7 +141,9 @@ export * from "./takeWhile";
 export * from "./tap";
 export * from "./times";
 export * from "./toCamelCase";
+export * from "./toKebabCase";
 export * from "./toLowerCase";
+export * from "./toSnakeCase";
 export * from "./toUpperCase";
 export * from "./uncapitalize";
 export * from "./unique";
diff --git a/src/mergeDeep.ts b/src/mergeDeep.ts
index 88d2257..dbfc51f 100644
--- a/src/mergeDeep.ts
+++ b/src/mergeDeep.ts
@@ -15,10 +15,10 @@ import { purry } from "./purry";
  * @dataFirst
  * @category Object
  */
-export function mergeDeep<
-  Destination extends Record<string, unknown>,
-  Source extends Record<string, unknown>,
->(destination: Destination, source: Source): MergeDeep<Destination, Source>;
+export function mergeDeep<Destination extends object, Source extends object>(
+  destination: Destination,
+  source: Source,
+): MergeDeep<Destination, Source>;
 
 /**
  * Merges the `source` object into the `destination` object. The merge is similar to performing `{ ...destination, ... source }` (where disjoint values from each object would be copied as-is, and for any overlapping props the value from `source` would be used); But for *each prop* (`p`), if **both** `destination` and `source` have a **plain-object** as a value, the value would be taken as the result of recursively deepMerging them (`result.p === deepMerge(destination.p, source.p)`).
@@ -35,18 +35,19 @@ export function mergeDeep<
  * @dataLast
  * @category Object
  */
-export function mergeDeep<
-  Destination extends Record<string, unknown>,
-  Source extends Record<string, unknown>,
->(source: Source): (target: Destination) => MergeDeep<Destination, Source>;
+export function mergeDeep<Source extends object>(
+  source: Source,
+): <Destination extends object>(
+  target: Destination,
+) => MergeDeep<Destination, Source>;
 
 export function mergeDeep(...args: ReadonlyArray<unknown>): unknown {
   return purry(mergeDeepImplementation, args);
 }
 
 function mergeDeepImplementation<
-  Destination extends Record<string, unknown>,
-  Source extends Record<string, unknown>,
+  Destination extends object,
+  Source extends object,
 >(destination: Destination, source: Source): MergeDeep<Destination, Source> {
   // At this point the output is already merged, simply not deeply merged.
   const output = { ...destination, ...source } as Record<
diff --git a/src/toCamelCase.ts b/src/toCamelCase.ts
index 67d400c..54b0e8b 100644
--- a/src/toCamelCase.ts
+++ b/src/toCamelCase.ts
@@ -18,7 +18,9 @@ const DEFAULT_OPTIONS = {
  * the runtime implementation of type-fest's [`CamelCase` type](https://github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts).
  *
  * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
- * and `uncapitalize`.
+ * `uncapitalize`, `toKebabCase`, and `toSnakeCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  *
  * For *PascalCase* use `capitalize(toCamelCase(data))`.
  *
@@ -48,7 +50,7 @@ export function toCamelCase<
  * the runtime implementation of type-fest's [`CamelCase` type](https://github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts).
  *
  * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
- * and `uncapitalize`.
+ * `uncapitalize`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  *
diff --git a/src/toKebabCase.ts b/src/toKebabCase.ts
new file mode 100644
index 0000000..6b59157
--- /dev/null
+++ b/src/toKebabCase.ts
@@ -0,0 +1,48 @@
+import { splitWords } from "./internal/splitWords";
+import { purry } from "./purry";
+
+/**
+ * Convert a text to kebab-Case by splitting it into words and joining them back
+ * together with "-", and then lowering the case of the result.
+ *
+ * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ *
+ * @param data - A string.
+ * @signature
+ *   R.toKebabCase(data);
+ * @example
+ *   R.toKebabCase("hello world"); // "hello-world"
+ *   R.toKebabCase("__HELLO_WORLD__"); // "hello-world"
+ * @dataFirst
+ * @category String
+ */
+export function toKebabCase(data: string): string;
+
+/**
+ * Convert a text to kebabCase by splitting it into words and joining them back
+ * together with "-", and then lowering the case of the result.
+ *
+ * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ *
+ * @signature
+ *   R.toKebabCase()(data);
+ * @example
+ *   R.pipe("hello world", R.toKebabCase()); // "hello-world"
+ *   R.pipe("__HELLO_WORLD__", toKebabCase()); // "hello-world"
+ * @dataLast
+ * @category String
+ */
+export function toKebabCase(): (data: string) => string;
+
+export function toKebabCase(...args: ReadonlyArray<unknown>): unknown {
+  return purry(toKebabCaseImplementation, args);
+}
+
+const toKebabCaseImplementation = (data: string): string =>
+  splitWords(data).join("-").toLowerCase();
diff --git a/src/toLowerCase.ts b/src/toLowerCase.ts
index bf09cc1..b924421 100644
--- a/src/toLowerCase.ts
+++ b/src/toLowerCase.ts
@@ -6,7 +6,7 @@ import { purry } from "./purry";
  * utility type for typing.
  *
  * For other case manipulations see: `toUpperCase`, `capitalize`,
- * `uncapitalize`, and `toCamelCase`.
+ * `uncapitalize`, `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  * If the output is intended for display (on a browser) consider using
@@ -29,7 +29,7 @@ export function toLowerCase<T extends string>(data: T): Lowercase<T>;
  * utility type for typing.
  *
  * For other case manipulations see: `toUpperCase`, `capitalize`,
- * `uncapitalize`, and `toCamelCase`.
+ * `uncapitalize`, `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  * If the output is intended for display (on a browser) consider using
diff --git a/src/toSnakeCase.ts b/src/toSnakeCase.ts
new file mode 100644
index 0000000..bc90855
--- /dev/null
+++ b/src/toSnakeCase.ts
@@ -0,0 +1,48 @@
+import { splitWords } from "./internal/splitWords";
+import { purry } from "./purry";
+
+/**
+ * Convert a text to snake-case by splitting it into words and joining them back
+ * together with "_", and then lowering the case of the result.
+ *
+ * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
+ * `uncapitalize`, `toCamelCase`, and `toKebabCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ *
+ * @param data - A string.
+ * @signature
+ *   R.toSnakeCase(data);
+ * @example
+ *   R.toSnakeCase("hello world"); // "hello_world"
+ *   R.toSnakeCase("__HELLO_WORLD__"); // "hello_world"
+ * @dataFirst
+ * @category String
+ */
+export function toSnakeCase(data: string): string;
+
+/**
+ * Convert a text to snake-case by splitting it into words and joining them back
+ * together with "_", and then lowering the case of the result.
+ *
+ * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
+ * `uncapitalize`, `toCamelCase`, and `toKebabCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ *
+ * @signature
+ *   R.toSnakeCase()(data);
+ * @example
+ *   R.pipe("hello world", R.toSnakeCase()); // "hello_world"
+ *   R.pipe("__HELLO_WORLD__", toSnakeCase()); // "hello_world"
+ * @dataLast
+ * @category String
+ */
+export function toSnakeCase(): (data: string) => string;
+
+export function toSnakeCase(...args: ReadonlyArray<unknown>): unknown {
+  return purry(toSnakeCaseImplementation, args);
+}
+
+const toSnakeCaseImplementation = (data: string): string =>
+  splitWords(data).join("_").toLowerCase();
diff --git a/src/toUpperCase.ts b/src/toUpperCase.ts
index 6435f19..787f8c7 100644
--- a/src/toUpperCase.ts
+++ b/src/toUpperCase.ts
@@ -7,7 +7,7 @@ import { purry } from "./purry";
  * utility type for typing.
  *
  * For other case manipulations see: `toLowerCase`, `capitalize`,
- * `uncapitalize`, and `toCamelCase`.
+ * `uncapitalize`, `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  * If the output is intended for display (on a browser) consider using
@@ -31,7 +31,7 @@ export function toUpperCase<T extends string>(data: T): Uppercase<T>;
  * utility type for typing.
  *
  * For other case manipulations see: `toLowerCase`, `capitalize`,
- * `uncapitalize`, and `toCamelCase`.
+ * `uncapitalize`, `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  * If the output is intended for display (on a browser) consider using
diff --git a/src/uncapitalize.ts b/src/uncapitalize.ts
index 83f2f26..021fbab 100644
--- a/src/uncapitalize.ts
+++ b/src/uncapitalize.ts
@@ -7,7 +7,9 @@ import { purry } from "./purry";
  * utility type for typing.
  *
  * For other case manipulations see: `toUpperCase`, `toLowerCase`, `capitalize`,
- * and `toCamelCase`.
+ * `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  *
  * @param data - A string.
  * @signature
@@ -26,7 +28,7 @@ export function uncapitalize<T extends string>(data: T): Uncapitalize<T>;
  * utility type for typing.
  *
  * For other case manipulations see: `toUpperCase`, `toLowerCase`, `capitalize`,
- * and `toCamelCase`.
+ * `toCamelCase`, `toKebabCase`, and `toSnakeCase`.
  *
  * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
  *


Test patch:
diff --git a/src/mergeDeep.test-d.ts b/src/mergeDeep.test-d.ts
index 524eb5a..68eeeda 100644
--- a/src/mergeDeep.test-d.ts
+++ b/src/mergeDeep.test-d.ts
@@ -1,49 +1,86 @@
 import { mergeDeep } from "./mergeDeep";
 
 it("trivially merges disjoint objects", () => {
-  const a = { foo: "bar" };
-  const b = { bar: "baz" };
-  const result = mergeDeep(a, b);
+  const result = mergeDeep(
+    { foo: "bar" } as { foo: string },
+    { bar: "baz" } as { bar: string },
+  );
   expectTypeOf(result).toEqualTypeOf<{ foo: string; bar: string }>();
 });
 
 it("merges fully overlapping types", () => {
-  const a = { foo: "bar" };
-  const b = { foo: "baz" };
-  const result = mergeDeep(a, b);
+  const result = mergeDeep(
+    { foo: "bar" } as { foo: string },
+    { foo: "baz" } as { foo: string },
+  );
   expectTypeOf(result).toEqualTypeOf<{ foo: string }>();
 });
 
 it("merges semi-overlapping types", () => {
-  const a = { foo: "bar", x: 1 };
-  const b = { foo: "baz", y: 2 };
-  const result = mergeDeep(a, b);
+  const result = mergeDeep(
+    { foo: "bar", x: 1 } as { foo: string; x: number },
+    { foo: "baz", y: 2 } as { foo: string; y: number },
+  );
   expectTypeOf(result).toEqualTypeOf<{ foo: string; x: number; y: number }>();
 });
 
 it("deeply merges", () => {
-  const a = { foo: { bar: "baz" } };
-  const b = { foo: { qux: "quux" } };
-  const result = mergeDeep(a, b);
+  const result = mergeDeep(
+    { foo: { bar: "bar" } } as { foo: { bar: string } },
+    { foo: { qux: "qux" } } as { foo: { qux: string } },
+  );
   expectTypeOf(result).toEqualTypeOf<{ foo: { bar: string; qux: string } }>();
 });
 
 it("overrides types", () => {
-  const a = { foo: { bar: "baz" } };
-  const b = { foo: "qux" };
-  expectTypeOf(mergeDeep(a, b)).toEqualTypeOf<typeof b>();
-  expectTypeOf(mergeDeep(b, a)).toEqualTypeOf<typeof a>();
+  const a = { foo: { bar: "baz" } } as { foo: { bar: string } };
+  const b = { foo: "qux" } as { foo: string };
+
+  const resultAB = mergeDeep(a, b);
+  expectTypeOf(resultAB).toEqualTypeOf<{ foo: string }>();
+
+  const resultBA = mergeDeep(b, a);
+  expectTypeOf(resultBA).toEqualTypeOf<{ foo: { bar: string } }>();
 });
 
 it("doesn't spread arrays", () => {
-  const a = { foo: ["bar"] as const };
-  const b = { foo: ["baz"] as const };
-  const result = mergeDeep(a, b);
-  expectTypeOf(result).toEqualTypeOf<{ foo: readonly ["baz"] }>();
+  const result = mergeDeep(
+    { foo: ["bar"] } as const,
+    { foo: ["baz"] } as const,
+  );
+  expectTypeOf(result).toEqualTypeOf<{ readonly foo: readonly ["baz"] }>();
 });
 
 it("doesn't recurse into arrays", () => {
-  const a = { foo: [{ bar: "baz" }] };
-  const b = { foo: [{ bar: "hello, world" }] };
-  expectTypeOf(mergeDeep(a, b)).toEqualTypeOf<typeof b>();
+  const result = mergeDeep(
+    { foo: [{ bar: "baz", x: 123 }] } as {
+      foo: Array<{ bar: string; x: number }>;
+    },
+    { foo: [{ bar: "hello, world", y: 456 }] } as {
+      foo: Array<{ bar: string; y: number }>;
+    },
+  );
+  expectTypeOf(result).toEqualTypeOf<{
+    foo: Array<{ bar: string; y: number }>;
+  }>();
+});
+
+it("works with interfaces", () => {
+  // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Intentional
+  interface Foo {
+    a: string;
+    b: number;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Intentional
+  interface Bar {
+    a: string;
+    c: boolean;
+  }
+
+  const result = mergeDeep(
+    { a: "foo", b: 123 } as Foo,
+    { a: "bar", c: true } as Bar,
+  );
+  expectTypeOf(result).toEqualTypeOf<{ a: string; b: number; c: boolean }>();
 });
diff --git a/src/toKebabCase.test.ts b/src/toKebabCase.test.ts
new file mode 100644
index 0000000..93bf309
--- /dev/null
+++ b/src/toKebabCase.test.ts
@@ -0,0 +1,94 @@
+import { pipe } from "./pipe";
+import { toKebabCase } from "./toKebabCase";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    expect(toKebabCase("hello world")).toBe("hello-world");
+  });
+
+  test("on upper case", () => {
+    expect(toKebabCase("HELLO WORLD")).toBe("hello-world");
+  });
+
+  test("on mixed case", () => {
+    expect(toKebabCase("HeLlO WoRlD")).toBe("he-ll-o-wo-rl-d");
+  });
+
+  test("on snake case", () => {
+    expect(toKebabCase("hello_world")).toBe("hello-world");
+  });
+
+  test("on camel case", () => {
+    expect(toKebabCase("helloWorld")).toBe("hello-world");
+  });
+
+  test("on kebab case", () => {
+    expect(toKebabCase("hello-world")).toBe("hello-world");
+  });
+
+  test("on prefixed string", () => {
+    expect(toKebabCase("__HELLO_WORLD__")).toBe("hello-world");
+  });
+
+  test("on string with multiple delimiters", () => {
+    expect(toKebabCase("foo---bar")).toBe("foo-bar");
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    expect(pipe("hello world", toKebabCase())).toBe("hello-world");
+  });
+
+  test("on upper case", () => {
+    expect(pipe("HELLO WORLD", toKebabCase())).toBe("hello-world");
+  });
+
+  test("on mixed case", () => {
+    expect(pipe("HeLlO WoRlD", toKebabCase())).toBe("he-ll-o-wo-rl-d");
+  });
+
+  test("on snake case", () => {
+    expect(pipe("hello_world", toKebabCase())).toBe("hello-world");
+  });
+
+  test("on camel case", () => {
+    expect(pipe("helloWorld", toKebabCase())).toBe("hello-world");
+  });
+
+  test("on kebab case", () => {
+    expect(pipe("hello-world", toKebabCase())).toBe("hello-world");
+  });
+});
+
+describe("type-fest cases", () => {
+  test.each(["fooBar", "foo-bar", "foo bar", "foo_bar"])(
+    "%s to be foo-bar",
+    (str) => {
+      expect(toKebabCase(str)).toBe("foo-bar");
+    },
+  );
+
+  test("trivial case", () => {
+    expect(toKebabCase("foobar")).toBe("foobar");
+  });
+});
+
+describe.each([
+  "foo bar",
+  "Foo bar",
+  "foo Bar",
+  "Foo Bar",
+  "FOO BAR",
+  "fooBar",
+  "--foo-bar--",
+  "__foo_bar__",
+])("lodash case: %s", (str) => {
+  test("should convert string to kebab case", () => {
+    expect(toKebabCase(str)).toBe("foo-bar");
+  });
+
+  test("should handle double-converting strings", () => {
+    expect(toKebabCase(toKebabCase(str))).toBe("foo-bar");
+  });
+});
diff --git a/src/toSnakeCase.test.ts b/src/toSnakeCase.test.ts
new file mode 100644
index 0000000..0ec73a6
--- /dev/null
+++ b/src/toSnakeCase.test.ts
@@ -0,0 +1,94 @@
+import { pipe } from "./pipe";
+import { toSnakeCase } from "./toSnakeCase";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    expect(toSnakeCase("hello world")).toBe("hello_world");
+  });
+
+  test("on upper case", () => {
+    expect(toSnakeCase("HELLO WORLD")).toBe("hello_world");
+  });
+
+  test("on mixed case", () => {
+    expect(toSnakeCase("HeLlO WoRlD")).toBe("he_ll_o_wo_rl_d");
+  });
+
+  test("on snake case", () => {
+    expect(toSnakeCase("hello_world")).toBe("hello_world");
+  });
+
+  test("on camel case", () => {
+    expect(toSnakeCase("helloWorld")).toBe("hello_world");
+  });
+
+  test("on kebab case", () => {
+    expect(toSnakeCase("hello-world")).toBe("hello_world");
+  });
+
+  test("on prefixed string", () => {
+    expect(toSnakeCase("__HELLO_WORLD__")).toBe("hello_world");
+  });
+
+  test("on string with multiple delimiters", () => {
+    expect(toSnakeCase("foo---bar")).toBe("foo_bar");
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    expect(pipe("hello world", toSnakeCase())).toBe("hello_world");
+  });
+
+  test("on upper case", () => {
+    expect(pipe("HELLO WORLD", toSnakeCase())).toBe("hello_world");
+  });
+
+  test("on mixed case", () => {
+    expect(pipe("HeLlO WoRlD", toSnakeCase())).toBe("he_ll_o_wo_rl_d");
+  });
+
+  test("on snake case", () => {
+    expect(pipe("hello_world", toSnakeCase())).toBe("hello_world");
+  });
+
+  test("on camel case", () => {
+    expect(pipe("helloWorld", toSnakeCase())).toBe("hello_world");
+  });
+
+  test("on kebab case", () => {
+    expect(pipe("hello-world", toSnakeCase())).toBe("hello_world");
+  });
+});
+
+describe("type-fest cases", () => {
+  test.each(["fooBar", "foo-bar", "foo bar", "foo_bar"])(
+    "%s to be foo-bar",
+    (str) => {
+      expect(toSnakeCase(str)).toBe("foo_bar");
+    },
+  );
+
+  test("trivial case", () => {
+    expect(toSnakeCase("foobar")).toBe("foobar");
+  });
+});
+
+describe.each([
+  "foo bar",
+  "Foo bar",
+  "foo Bar",
+  "Foo Bar",
+  "FOO BAR",
+  "fooBar",
+  "--foo-bar--",
+  "__foo_bar__",
+])("lodash case: %s", (str) => {
+  test("should convert string to kebab case", () => {
+    expect(toSnakeCase(str)).toBe("foo_bar");
+  });
+
+  test("should handle double-converting strings", () => {
+    expect(toSnakeCase(toSnakeCase(str))).toBe("foo_bar");
+  });
+});