Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve DelimiterCase #930

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 20 additions & 57 deletions source/delimiter-case.d.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,20 @@
import type {UpperCaseCharacters, WordSeparators} from './internal';

// Transforms a string that is fully uppercase into a fully lowercase version. Needed to add support for SCREAMING_SNAKE_CASE, see https://github.com/sindresorhus/type-fest/issues/385
type UpperCaseToLowerCase<T extends string> = T extends Uppercase<T> ? Lowercase<T> : T;

// This implementation does not support SCREAMING_SNAKE_CASE, it is used internally by `SplitIncludingDelimiters`.
type SplitIncludingDelimiters_<Source extends string, Delimiter extends string> =
Source extends '' ? [] :
Source extends `${infer FirstPart}${Delimiter}${infer SecondPart}` ?
(
Source extends `${FirstPart}${infer UsedDelimiter}${SecondPart}`
? UsedDelimiter extends Delimiter
? Source extends `${infer FirstPart}${UsedDelimiter}${infer SecondPart}`
? [...SplitIncludingDelimiters<FirstPart, Delimiter>, UsedDelimiter, ...SplitIncludingDelimiters<SecondPart, Delimiter>]
: never
: never
: never
) :
[Source];

/**
Unlike a simpler split, this one includes the delimiter splitted on in the resulting array literal. This is to enable splitting on, for example, upper-case characters.

@category Template literal
*/
export type SplitIncludingDelimiters<Source extends string, Delimiter extends string> = SplitIncludingDelimiters_<UpperCaseToLowerCase<Source>, Delimiter>;
import type {SplitWords, SplitWordsOptions} from './split-words';

/**
Format a specific part of the splitted string literal that `StringArrayToDelimiterCase<>` fuses together, ensuring desired casing.

@see StringArrayToDelimiterCase
Convert an array of words to delimiter case starting with a delimiter with input capitalization.
*/
type StringPartToDelimiterCase<StringPart extends string, Start extends boolean, UsedWordSeparators extends string, UsedUpperCaseCharacters extends string, Delimiter extends string> =
StringPart extends UsedWordSeparators ? Delimiter :
Start extends true ? Lowercase<StringPart> :
StringPart extends UsedUpperCaseCharacters ? `${Delimiter}${Lowercase<StringPart>}` :
StringPart;

/**
Takes the result of a splitted string literal and recursively concatenates it together into the desired casing.

It receives `UsedWordSeparators` and `UsedUpperCaseCharacters` as input to ensure it's fully encapsulated.

@see SplitIncludingDelimiters
*/
type StringArrayToDelimiterCase<Parts extends readonly any[], Start extends boolean, UsedWordSeparators extends string, UsedUpperCaseCharacters extends string, Delimiter extends string> =
Parts extends [`${infer FirstPart}`, ...infer RemainingParts]
? `${StringPartToDelimiterCase<FirstPart, Start, UsedWordSeparators, UsedUpperCaseCharacters, Delimiter>}${StringArrayToDelimiterCase<RemainingParts, false, UsedWordSeparators, UsedUpperCaseCharacters, Delimiter>}`
: Parts extends [string]
? string
: '';
type DelimiterCaseFromArray<
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gets the words split and inserts the delimited at the beginning and after every word

Like ['here', 'We', 'Go'] -> '#here#We#Go'

Words extends string[],
Delimiter extends string,
OutputString extends string = '',
> = Words extends [
infer FirstWord extends string,
...infer RemainingWords extends string[],
]
? `${Delimiter}${FirstWord}${DelimiterCaseFromArray<RemainingWords, Delimiter>}`
: OutputString;

type RemoveFirstLetter<S extends string> = S extends `${infer _}${infer Rest}` ? Rest : '';

/**
Convert a string literal to a custom string delimiter casing.
Expand All @@ -65,6 +31,7 @@ import type {DelimiterCase} from 'type-fest';
// Simple

const someVariable: DelimiterCase<'fooBar', '#'> = 'foo#bar';
const someVariableNoSplitOnNumber: DelimiterCase<'p2pNetwork', '#', {splitOnNumber: false}> = 'p2p#network';

// Advanced

Expand All @@ -87,13 +54,9 @@ const rawCliOptions: OddlyCasedProperties<SomeOptions> = {

@category Change case
@category Template literal
*/
export type DelimiterCase<Value, Delimiter extends string> = string extends Value ? Value : Value extends string
? StringArrayToDelimiterCase<
SplitIncludingDelimiters<Value, WordSeparators | UpperCaseCharacters>,
true,
WordSeparators,
UpperCaseCharacters,
Delimiter
>
*/
export type DelimiterCase<Value, Delimiter extends string, Options extends SplitWordsOptions = {splitOnNumber: true}> = Value extends string
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we

  1. 'hereWeGo' -> SplitWords -> ['here', 'We', 'Go']
  2. ['here', 'We', 'Go'] -> DelimiterCaseFromArray -> '#here#We#Go'
  3. '#here#We#Go' -> RemoveFirstLetter -> 'here#We#Go'
  4. 'here#We#Go' -> Lowercase -> 'here#we#go'

? string extends Value
? Value
: Lowercase<RemoveFirstLetter<DelimiterCaseFromArray<SplitWords<Value, Options>, Delimiter>>>
: Value;
4 changes: 3 additions & 1 deletion source/kebab-case.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {DelimiterCase} from './delimiter-case';
import type {SplitWordsOptions} from './split-words';

/**
Convert a string literal to kebab-case.
Expand All @@ -12,6 +13,7 @@ import type {KebabCase} from 'type-fest';
// Simple

const someVariable: KebabCase<'fooBar'> = 'foo-bar';
const someVariableNoSplitOnNumber: KebabCase<'p2pNetwork', {splitOnNumber: false}> = 'p2p-network';
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this new option on case changing types


// Advanced

Expand All @@ -35,4 +37,4 @@ const rawCliOptions: KebabCasedProperties<CliOptions> = {
@category Change case
@category Template literal
*/
export type KebabCase<Value> = DelimiterCase<Value, '-'>;
export type KebabCase<Value, Options extends SplitWordsOptions = {splitOnNumber: true}> = DelimiterCase<Value, '-', Options>;
22 changes: 6 additions & 16 deletions source/screaming-snake-case.d.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import type {SplitIncludingDelimiters} from './delimiter-case';
import type {SnakeCase} from './snake-case';
import type {Includes} from './includes';

/**
Returns a boolean for whether the string is screaming snake case.
*/
type IsScreamingSnakeCase<Value extends string> = Value extends Uppercase<Value>
? Includes<SplitIncludingDelimiters<Lowercase<Value>, '_'>, '_'> extends true
? true
: false
: false;
import type {SplitWordsOptions} from './split-words';

/**
Convert a string literal to screaming-snake-case.
Expand All @@ -21,13 +11,13 @@ This can be useful when, for example, converting a camel-cased object property t
import type {ScreamingSnakeCase} from 'type-fest';

const someVariable: ScreamingSnakeCase<'fooBar'> = 'FOO_BAR';
const someVariableNoSplitOnNumber: ScreamingSnakeCase<'p2pNetwork', {splitOnNumber: false}> = 'P2P_NETWORK';

```

@category Change case
@category Template literal
*/
export type ScreamingSnakeCase<Value> = Value extends string
? IsScreamingSnakeCase<Value> extends true
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can bring this back, but I believe it shouldn't be necessary, especially that we don't use it for other things like snake case or kebab case

? Value
: Uppercase<SnakeCase<Value>>
*/
export type ScreamingSnakeCase<Value, Options extends SplitWordsOptions = {splitOnNumber: true}> = Value extends string
? Uppercase<SnakeCase<Value, Options>>
: Value;
4 changes: 3 additions & 1 deletion source/snake-case.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {DelimiterCase} from './delimiter-case';
import type {SplitWordsOptions} from './split-words';

/**
Convert a string literal to snake-case.
Expand All @@ -12,6 +13,7 @@ import type {SnakeCase} from 'type-fest';
// Simple

const someVariable: SnakeCase<'fooBar'> = 'foo_bar';
const someVariableNoSplitOnNumber: SnakeCase<'p2pNetwork', {splitOnNumber: false}> = 'p2p_network';

// Advanced

Expand All @@ -35,4 +37,4 @@ const dbResult: SnakeCasedProperties<ModelProps> = {
@category Change case
@category Template literal
*/
export type SnakeCase<Value> = DelimiterCase<Value, '_'>;
export type SnakeCase<Value, Options extends SplitWordsOptions = {splitOnNumber: true}> = DelimiterCase<Value, '_', Options>;
35 changes: 23 additions & 12 deletions source/split-words.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type RemoveLastCharacter<Sentence extends string, Character extends string> = Se
? SkipEmptyWord<LeftSide>
: never;

export type SplitWordsOptions = {splitOnNumber: boolean};
/**
Split a string (almost) like Lodash's `_.words()` function.

Expand All @@ -20,6 +21,7 @@ type Words1 = SplitWords<'helloWORLD'>; // ['hello', 'WORLD']
type Words2 = SplitWords<'hello-world'>; // ['hello', 'world']
type Words3 = SplitWords<'--hello the_world'>; // ['hello', 'the', 'world']
type Words4 = SplitWords<'lifeIs42'>; // ['life', 'Is', '42']
type Words5 = SplitWords<'p2pNetwork', { splitOnNumber: false }>; // ['p2p', 'Network']
```

@internal
Expand All @@ -28,6 +30,7 @@ type Words4 = SplitWords<'lifeIs42'>; // ['life', 'Is', '42']
*/
export type SplitWords<
Sentence extends string,
Options extends SplitWordsOptions = {splitOnNumber: true},
LastCharacter extends string = '',
CurrentWord extends string = '',
> = Sentence extends `${infer FirstCharacter}${infer RemainingCharacters}`
Expand All @@ -36,22 +39,30 @@ export type SplitWords<
? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters>]
: LastCharacter extends ''
// Fist char of word
? SplitWords<RemainingCharacters, FirstCharacter, FirstCharacter>
// Case change: non-numeric to numeric, push word
? SplitWords<RemainingCharacters, Options, FirstCharacter, FirstCharacter>
// Case change: non-numeric to numeric
: [false, true] extends [IsNumeric<LastCharacter>, IsNumeric<FirstCharacter>]
? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters, FirstCharacter, FirstCharacter>]
// Case change: numeric to non-numeric, push word
// Split on number: push word
? Options['splitOnNumber'] extends true
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there's a numeric/non-numeric case change I added and used a new option splitOnNumber which depending on how it is set will either

  1. When set to true - Like before split words on number
  2. When set to false - concat to current word

? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters, Options, FirstCharacter, FirstCharacter>]
// No split on number: concat word
: SplitWords<RemainingCharacters, Options, FirstCharacter, `${CurrentWord}${FirstCharacter}`>
// Case change: numeric to non-numeric
: [true, false] extends [IsNumeric<LastCharacter>, IsNumeric<FirstCharacter>]
? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters, FirstCharacter, FirstCharacter>]
// Split on number: push word
? Options['splitOnNumber'] extends true
? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters, Options, FirstCharacter, FirstCharacter>]
// No split on number: concat word
: SplitWords<RemainingCharacters, Options, FirstCharacter, `${CurrentWord}${FirstCharacter}`>
// No case change: concat word
: [true, true] extends [IsNumeric<LastCharacter>, IsNumeric<FirstCharacter>]
? SplitWords<RemainingCharacters, FirstCharacter, `${CurrentWord}${FirstCharacter}`>
// Case change: lower to upper, push word
? SplitWords<RemainingCharacters, Options, FirstCharacter, `${CurrentWord}${FirstCharacter}`>
// Case change: lower to upper, push word
: [true, true] extends [IsLowerCase<LastCharacter>, IsUpperCase<FirstCharacter>]
? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters, FirstCharacter, FirstCharacter>]
// Case change: upper to lower, brings back the last character, push word
? [...SkipEmptyWord<CurrentWord>, ...SplitWords<RemainingCharacters, Options, FirstCharacter, FirstCharacter>]
// Case change: upper to lower, brings back the last character, push word
: [true, true] extends [IsUpperCase<LastCharacter>, IsLowerCase<FirstCharacter>]
? [...RemoveLastCharacter<CurrentWord, LastCharacter>, ...SplitWords<RemainingCharacters, FirstCharacter, `${LastCharacter}${FirstCharacter}`>]
// No case change: concat word
: SplitWords<RemainingCharacters, FirstCharacter, `${CurrentWord}${FirstCharacter}`>
? [...RemoveLastCharacter<CurrentWord, LastCharacter>, ...SplitWords<RemainingCharacters, Options, FirstCharacter, `${LastCharacter}${FirstCharacter}`>]
// No case change: concat word
: SplitWords<RemainingCharacters, Options, FirstCharacter, `${CurrentWord}${FirstCharacter}`>
: [...SkipEmptyWord<CurrentWord>];
71 changes: 52 additions & 19 deletions test-d/delimiter-case.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import {expectType, expectAssignable} from 'tsd';
import type {UpperCaseCharacters, WordSeparators} from '../source/internal';
import type {SplitIncludingDelimiters, DelimiterCase} from '../source/delimiter-case';

const splitFromCamel: SplitIncludingDelimiters<'fooBar', WordSeparators | UpperCaseCharacters> = ['foo', 'B', 'ar'];
expectType<['foo', 'B', 'ar']>(splitFromCamel);
const splitFromComplexCamel: SplitIncludingDelimiters<'fooBarAbc123', WordSeparators | UpperCaseCharacters> = ['foo', 'B', 'ar', 'A', 'bc123'];
expectType<['foo', 'B', 'ar', 'A', 'bc123']>(splitFromComplexCamel);
const splitFromWordSeparators: SplitIncludingDelimiters<'foo-bar_car far', WordSeparators> = ['foo', '-', 'bar', '_', 'car', ' ', 'far'];
expectType<['foo', '-', 'bar', '_', 'car', ' ', 'far']>(splitFromWordSeparators);
const splitFromScreamingSnakeCase: SplitIncludingDelimiters<'FOO_BAR', WordSeparators | UpperCaseCharacters> = ['foo', '_', 'bar'];
expectType<['foo', '_', 'bar']>(splitFromScreamingSnakeCase);
import type {DelimiterCase} from '../source/delimiter-case';

// DelimiterCase
const delimiterFromCamel: DelimiterCase<'fooBar', '#'> = 'foo#bar';
expectType<'foo#bar'>(delimiterFromCamel);

const delimiterFromComplexCamel: DelimiterCase<'fooBarAbc123', '#'> = 'foo#bar#abc123';
expectType<'foo#bar#abc123'>(delimiterFromComplexCamel);
const delimiterFromComplexCamel: DelimiterCase<'fooBarAbc123', '#'> = 'foo#bar#abc#123';
expectType<'foo#bar#abc#123'>(delimiterFromComplexCamel);

const delimiterFromComplexCamelSplitOnNumber: DelimiterCase<
'fooBarAbc123',
'#',
{splitOnNumber: true}
> = 'foo#bar#abc#123';
expectType<'foo#bar#abc#123'>(delimiterFromComplexCamelSplitOnNumber);

const delimiterFromComplexCamelNoSplitOnNumber: DelimiterCase<'fooBarAbc123', '#', {splitOnNumber: false}> = 'foo#bar#abc123';
expectType<'foo#bar#abc123'>(delimiterFromComplexCamelNoSplitOnNumber);

const delimiterNumberInTheMiddle: DelimiterCase<'p2pNetwork', '#'> = 'p#2#p#network';
expectType<'p#2#p#network'>(delimiterNumberInTheMiddle);

const delimiterNumberInTheMiddleNoSplitOnNumber: DelimiterCase<'p2pNetwork', '#', {splitOnNumber: false}> = 'p2p#network';
expectType<'p2p#network'>(delimiterNumberInTheMiddleNoSplitOnNumber);

const delimiterFromPascal: DelimiterCase<'FooBar', '#'> = 'foo#bar';
expectType<'foo#bar'>(delimiterFromPascal);
Expand All @@ -42,21 +48,48 @@ expectType<'foobar'>(noDelimiterFromMono);
const delimiterFromMixed: DelimiterCase<'foo-bar_abc xyzBarFoo', '#'> = 'foo#bar#abc#xyz#bar#foo';
expectType<'foo#bar#abc#xyz#bar#foo'>(delimiterFromMixed);

const delimiterFromVendorPrefixedCssProperty: DelimiterCase<'-webkit-animation', '#'> = '#webkit#animation';
expectType<'#webkit#animation'>(delimiterFromVendorPrefixedCssProperty);
const delimiterFromVendorPrefixedCssProperty: DelimiterCase<'-webkit-animation', '#'> = 'webkit#animation';
expectType<'webkit#animation'>(delimiterFromVendorPrefixedCssProperty);

const delimiterFromDoublePrefixedKebab: DelimiterCase<'--very-prefixed', '#'> = '##very#prefixed';
expectType<'##very#prefixed'>(delimiterFromDoublePrefixedKebab);
const delimiterFromDoublePrefixedKebab: DelimiterCase<'--very-prefixed', '#'> = 'very#prefixed';
expectType<'very#prefixed'>(delimiterFromDoublePrefixedKebab);

const delimiterFromRepeatedSeparators: DelimiterCase<'foo____bar', '#'> = 'foo####bar';
expectType<'foo####bar'>(delimiterFromRepeatedSeparators);
const delimiterFromRepeatedSeparators: DelimiterCase<'foo____bar', '#'> = 'foo#bar';
expectType<'foo#bar'>(delimiterFromRepeatedSeparators);

const delimiterFromString: DelimiterCase<string, '#'> = 'foobar';
expectType<string>(delimiterFromString);

const delimiterFromScreamingSnake: DelimiterCase<'FOO_BAR', '#'> = 'foo#bar';
expectType<'foo#bar'>(delimiterFromScreamingSnake);

const delimiterFromMixed2: DelimiterCase<'parseHTML', '#'> = 'parse#html';
expectType<'parse#html'>(delimiterFromMixed2);

const delimiterFromMixed3: DelimiterCase<'parseHTMLItem', '#'> = 'parse#html#item';
expectType<'parse#html#item'>(delimiterFromMixed3);

const delimiterFromNumberInTheMiddleSplitOnNumber: DelimiterCase<'foo2bar', '#'> = 'foo#2#bar';
expectType<'foo#2#bar'>(delimiterFromNumberInTheMiddleSplitOnNumber);

const delimiterFromNumberInTheMiddleSplitOnNumberEdgeCase: DelimiterCase<'foO2Bar', '#'> = 'fo#o#2#bar';
expectType<'fo#o#2#bar'>(delimiterFromNumberInTheMiddleSplitOnNumberEdgeCase);

const delimiterFromNumberInTheMiddleSplitOnNumberEdgeCase2: DelimiterCase<'foO2bar', '#'> = 'fo#o#2#bar';
expectType<'fo#o#2#bar'>(delimiterFromNumberInTheMiddleSplitOnNumberEdgeCase2);

const delimiterFromNumberInTheMiddleNoSplitOnNumber: DelimiterCase<'foo2bar', '#', {splitOnNumber: false}> = 'foo2bar';
expectType<'foo2bar'>(delimiterFromNumberInTheMiddleNoSplitOnNumber);

const delimiterFromNumberInTheMiddleNoSplitOnNumberEdgeCase: DelimiterCase<'foo2Bar', '#', {splitOnNumber: false}> = 'foo2#bar';
expectType<'foo2#bar'>(delimiterFromNumberInTheMiddleNoSplitOnNumberEdgeCase);

const delimiterFromNumberInTheMiddleNoSplitOnNumberEdgeCase2: DelimiterCase<'foO2bar', '#', {splitOnNumber: false}> = 'fo#o2bar';
expectType<'fo#o2bar'>(delimiterFromNumberInTheMiddleNoSplitOnNumberEdgeCase2);

const delimiterFromNumberInTheMiddleNoSplitOnNumberEdgeCase3: DelimiterCase<'FOO22Bar', '#', {splitOnNumber: false}> = 'foo22#bar';
expectType<'foo22#bar'>(delimiterFromNumberInTheMiddleNoSplitOnNumberEdgeCase3);

// Verifying example
type OddCasedProperties<T> = {
[K in keyof T as DelimiterCase<K, '#'>]: T[K]
Expand Down
Loading