From 9d3c6b36300f6d3b8ce30b941a5341dddb5052e8 Mon Sep 17 00:00:00 2001 From: abhi-byte62 Date: Wed, 14 Oct 2026 10:56:15 +0530 Subject: [PATCH 1/3] fix(types): insert joiners in TrainCase and infer normalization options Resolves #110 Resolves #146 - Introduce JoinCapitalizedWords in types.ts to properly insert joiner delimiters between capitalized tokens in TrainCase instead of prefixing the accumulator - Pass Normalize option through to string and array generic branches of TrainCase - Update pascalCase overload parameter type to UserCaseOptions to preserve normalization flag inference - Add comprehensive type test suite for TrainCase and option inference in test/types.test-d.ts --- src/index.ts | 2 +- src/types.ts | 25 +++++++++++++++++++++++-- test/types.test-d.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index f1983fd..36f5452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,7 @@ export function pascalCase(): ""; export function pascalCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, ->(str: T, opts?: CaseOptions): PascalCase; +>(str: T, opts?: UserCaseOptions): PascalCase; export function pascalCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, diff --git a/src/types.ts b/src/types.ts index 9e7f1dd..2491d80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,27 @@ type JoinLowercaseWords< : JoinLowercaseWords}`> : Accumulator; +type JoinCapitalizedWords< + T extends readonly string[], + Joiner extends string, + Accumulator extends string = "", + Normalize extends boolean | undefined = false, +> = T extends readonly [infer F extends string, ...infer R extends string[]] + ? Accumulator extends "" + ? JoinCapitalizedWords< + R, + Joiner, + `${Capitalize : F>}`, + Normalize + > + : JoinCapitalizedWords< + R, + Joiner, + `${Accumulator}${Joiner}${Capitalize : F>}`, + Normalize + > + : Accumulator; + type LastOfArray = T extends [...any, infer R] ? R : never; type RemoveLastOfArray = T extends [...infer F, any] ? F @@ -155,10 +176,10 @@ export type TrainCase< ? string : T extends string ? SplitByCase extends readonly string[] - ? CapitalizedWords, Joiner> + ? JoinCapitalizedWords, Joiner, "", Normalize> : never : T extends readonly string[] - ? CapitalizedWords + ? JoinCapitalizedWords : never; export type FlatCase< diff --git a/test/types.test-d.ts b/test/types.test-d.ts index bd722b1..14bc4ed 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -4,7 +4,9 @@ import type { PascalCase, CamelCase, JoinByCase, + TrainCase, } from "../src/types"; +import { trainCase, titleCase } from "../src/index"; describe("SplitByCase", () => { test("types", () => { @@ -96,3 +98,34 @@ describe("JoinByCase", () => { assertType>("foo-bar"); }); }); + +describe("TrainCase", () => { + test("types", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test("string", () => { + assertType>(""); + assertType>("Foo"); + assertType>("Foo-Bar"); + assertType>("Foo-B-Ar"); + assertType>("Foo-Bar"); + assertType>("FOO-BAR"); + assertType>("Foo Bar Baz"); + }); + + test("array", () => { + assertType>("Foo-Bar"); + assertType>("Foo-Bar"); + assertType>("Foo Bar"); + }); + + test("inference with options", () => { + const res1 = trainCase("foo_bar", { normalize: true }); + expectTypeOf(res1).toEqualTypeOf<"Foo-Bar">(); + + const res2 = titleCase("foo_bar", { normalize: true }); + expectTypeOf(res2).toEqualTypeOf<"Foo Bar">(); + }); +}); From 6fbae5b593cf22d3f06d3fb083fecbd0c941fb8c Mon Sep 17 00:00:00 2001 From: abhi-byte62 Date: Thu, 15 Oct 2026 11:50:54 +0530 Subject: [PATCH 2/3] fix(types): skip empty words in JoinCapitalizedWords and add TitleCase type - Filter out empty tokens in JoinCapitalizedWords so consecutive delimiters (e.g. foo--bar) do not produce double joiners - Add TitleCase type with TitleCaseExceptions matching runtime article/conjunction handling - Update titleCase signature to return TitleCase - Add comprehensive type-level assertions for TitleCase and empty-word skipping --- src/index.ts | 8 ++-- src/types.ts | 88 +++++++++++++++++++++++++++++++++++++------- test/types.test-d.ts | 27 ++++++++++++++ 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index 36f5452..1159828 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import type { SplitByCase, CaseOptions, TrainCase, + TitleCase, FlatCase, } from "./types"; @@ -176,10 +177,7 @@ export function titleCase(): ""; export function titleCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, ->( - str: T, - opts?: UserCaseOptions, -): TrainCase; +>(str: T, opts?: UserCaseOptions): TitleCase; export function titleCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, @@ -191,7 +189,7 @@ export function titleCase< ? p.toLowerCase() : upperFirst(opts?.normalize ? p.toLowerCase() : p), ) - .join(" ") as TrainCase; + .join(" ") as TitleCase; } export * from "./types"; diff --git a/src/types.ts b/src/types.ts index 2491d80..17133fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,19 +40,66 @@ type JoinCapitalizedWords< Accumulator extends string = "", Normalize extends boolean | undefined = false, > = T extends readonly [infer F extends string, ...infer R extends string[]] - ? Accumulator extends "" - ? JoinCapitalizedWords< - R, - Joiner, - `${Capitalize : F>}`, - Normalize - > - : JoinCapitalizedWords< - R, - Joiner, - `${Accumulator}${Joiner}${Capitalize : F>}`, - Normalize - > + ? F extends "" + ? JoinCapitalizedWords + : Accumulator extends "" + ? JoinCapitalizedWords< + R, + Joiner, + `${Capitalize : F>}`, + Normalize + > + : JoinCapitalizedWords< + R, + Joiner, + `${Accumulator}${Joiner}${Capitalize : F>}`, + Normalize + > + : Accumulator; + +type TitleCaseExceptions = + | "a" + | "an" + | "and" + | "as" + | "at" + | "but" + | "by" + | "for" + | "if" + | "in" + | "is" + | "nor" + | "of" + | "on" + | "or" + | "the" + | "to" + | "with"; + +type JoinTitleCaseWords< + T extends readonly string[], + Joiner extends string = " ", + Accumulator extends string = "", + Normalize extends boolean | undefined = false, +> = T extends readonly [infer F extends string, ...infer R extends string[]] + ? F extends "" + ? JoinTitleCaseWords + : Accumulator extends "" + ? JoinTitleCaseWords< + R, + Joiner, + `${Capitalize : F>}`, + Normalize + > + : JoinTitleCaseWords< + R, + Joiner, + `${Accumulator}${Joiner}${Lowercase extends TitleCaseExceptions + ? Lowercase + : Capitalize : F>}`, + Normalize + > : Accumulator; type LastOfArray = T extends [...any, infer R] ? R : never; @@ -182,6 +229,21 @@ export type TrainCase< ? JoinCapitalizedWords : never; +export type TitleCase< + T, + Normalize extends boolean | undefined = false, +> = string extends T + ? string + : string[] extends T + ? string + : T extends string + ? SplitByCase extends readonly string[] + ? JoinTitleCaseWords, " ", "", Normalize> + : never + : T extends readonly string[] + ? JoinTitleCaseWords + : never; + export type FlatCase< T extends string | readonly string[], Joiner extends string = "", diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 14bc4ed..4960abc 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -5,6 +5,7 @@ import type { CamelCase, JoinByCase, TrainCase, + TitleCase, } from "../src/types"; import { trainCase, titleCase } from "../src/index"; @@ -110,6 +111,7 @@ describe("TrainCase", () => { assertType>("Foo"); assertType>("Foo-Bar"); assertType>("Foo-B-Ar"); + assertType>("Foo-Bar-Baz"); assertType>("Foo-Bar"); assertType>("FOO-BAR"); assertType>("Foo Bar Baz"); @@ -117,6 +119,7 @@ describe("TrainCase", () => { test("array", () => { assertType>("Foo-Bar"); + assertType>("Foo-Bar"); assertType>("Foo-Bar"); assertType>("Foo Bar"); }); @@ -127,5 +130,29 @@ describe("TrainCase", () => { const res2 = titleCase("foo_bar", { normalize: true }); expectTypeOf(res2).toEqualTypeOf<"Foo Bar">(); + + const res3 = titleCase("foo_and_bar", { normalize: true }); + expectTypeOf(res3).toEqualTypeOf<"Foo and Bar">(); + }); +}); + +describe("TitleCase", () => { + test("types", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test("string", () => { + assertType>(""); + assertType>("Foo"); + assertType>("Foo Bar"); + assertType>("Foo and Bar"); + assertType>("Foo and Bar"); + assertType>("And Foo"); + }); + + test("array", () => { + assertType>("Foo and Bar"); + assertType>("Foo and Bar"); }); }); From 385ec0e996f76ff048b9dd67332c8637e8e57dd2 Mon Sep 17 00:00:00 2001 From: abhi-byte62 Date: Sun, 25 Oct 2026 18:22:24 +0530 Subject: [PATCH 3/3] fix(titleCase): capitalize initial exception word at runtime to match type --- src/index.ts | 4 ++-- test/scule.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1159828..fdf84e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -184,8 +184,8 @@ export function titleCase< >(str?: T, opts?: UserCaseOptions) { return (Array.isArray(str) ? str : splitByCase(str as string)) .filter(Boolean) - .map((p) => - titleCaseExceptions.test(p) + .map((p, i) => + i > 0 && titleCaseExceptions.test(p) ? p.toLowerCase() : upperFirst(opts?.normalize ? p.toLowerCase() : p), ) diff --git a/test/scule.test.ts b/test/scule.test.ts index bd5ec3b..a00cda1 100644 --- a/test/scule.test.ts +++ b/test/scule.test.ts @@ -140,6 +140,7 @@ describe("titleCase", () => { ["foo", "Foo"], ["foo-bar", "Foo Bar"], ["this-IS-aTitle", "This is a Title"], + ["and_foo", "And Foo"], ])("%s => %s", (input, expected) => { expect(titleCase(input)).toMatchObject(expected); });