Skip to content

Commit

Permalink
Join: Support more cases (#594)
Browse files Browse the repository at this point in the history
  • Loading branch information
eranhirsch committed Apr 25, 2023
1 parent c365837 commit bb81314
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 10 deletions.
51 changes: 42 additions & 9 deletions source/join.d.ts
@@ -1,3 +1,13 @@
// The builtin `join` method supports all these natively in the same way that typescript handles them so we can safely accept all of them.
type JoinableItem = string | number | bigint | boolean | undefined | null;

// `null` and `undefined` are treated uniquely in the built-in join method, in a way that differs from the default `toString` that would result in the type `${undefined}`. That's why we need to handle it specifically with this helper.
// @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join#description
type NullishCoalesce<
Value extends JoinableItem,
Fallback extends string,
> = Value extends undefined | null ? NonNullable<Value> | Fallback : Value;

/**
Join an array of strings and/or numbers using the given string as a delimiter.
Expand All @@ -15,21 +25,44 @@ const path: Join<['foo', 'bar', 'baz'], '.'> = ['foo', 'bar', 'baz'].join('.');
// Only number items; result is: '1.2.3'
const path: Join<[1, 2, 3], '.'> = [1, 2, 3].join('.');
// Only bigint items; result is '1.2.3'
const path: Join<[1n, 2n, 3n], '.'> = [1n, 2n, 3n].join('.');
// Only boolean items; result is: 'true.false.true'
const path: Join<[true, false, true], '.'> = [true, false, true].join('.');
// Contains nullish items; result is: 'foo..baz..xyz'
const path: Join<['foo', undefined, 'baz', null, 'xyz'], '.'> = ['foo', undefined, 'baz', null, 'xyz'].join('.');
// Partial tuple shapes (rest param last); result is: `prefix.${string}`
const path: Join<['prefix', ...string[]], '.'> = ['prefix'].join('.');
// Partial tuple shapes (rest param first); result is: `${string}.suffix`
const path: Join<[...string[], 'suffix'], '.'> = ['suffix'].join('.');
// Tuples items with nullish unions; result is '.' | 'hello.' | '.world' | 'hello.world'
const path: Join<['hello' | undefined, 'world' | null], '.'> = ['hello', 'world'].join('.');
```
@category Array
@category Template literal
*/
export type Join<
Strings extends ReadonlyArray<string | number>,
Items extends readonly JoinableItem[],
Delimiter extends string,
> = Strings extends []
> = Items extends []
? ''
: Strings extends readonly [string | number]
? `${Strings[0]}`
: Strings extends readonly [
string | number,
...infer Rest extends ReadonlyArray<string | number>,
: Items extends readonly [JoinableItem?]
? `${NullishCoalesce<Items[0], ''>}`
: Items extends readonly [
infer First extends JoinableItem,
...infer Tail extends readonly JoinableItem[],
]
? `${Strings[0]}${Delimiter}${Join<Rest, Delimiter>}`
: string;
? `${NullishCoalesce<First, ''>}${Delimiter}${Join<Tail, Delimiter>}`
: Items extends readonly [
...infer Head extends readonly JoinableItem[],
infer Last extends JoinableItem,
]
? `${Join<Head, Delimiter>}${Delimiter}${NullishCoalesce<Last, ''>}`
: string;
26 changes: 25 additions & 1 deletion test-d/join.ts
Expand Up @@ -5,9 +5,18 @@ import type {Join} from '../index';
const generalTestVariantMixed: Join<['foo', 0, 'baz'], '.'> = 'foo.0.baz';
const generalTestVariantOnlyStrings: Join<['foo', 'bar', 'baz'], '.'> = 'foo.bar.baz';
const generalTestVariantOnlyNumbers: Join<[1, 2, 3], '.'> = '1.2.3';
const generalTestVariantOnlyBigints: Join<[1n, 2n, 3n], '.'> = '1.2.3';
const generalTestVariantOnlyBooleans: Join<[true, false, true], '.'> = 'true.false.true';
const generalTestVariantOnlyNullish: Join<[undefined, null, undefined], '.'> = '..';
const generalTestVariantNullish: Join<['foo', undefined, 'baz', null, 'xyz'], '.'> = 'foo..baz..xyz';
expectType<'foo.0.baz'>(generalTestVariantMixed);
expectType<'1.2.3'>(generalTestVariantOnlyNumbers);
expectType<'foo.bar.baz'>(generalTestVariantOnlyStrings);
expectType<'1.2.3'>(generalTestVariantOnlyNumbers);
expectType<'1.2.3'>(generalTestVariantOnlyBigints);
expectType<'true.false.true'>(generalTestVariantOnlyBooleans);
expectType<'..'>(generalTestVariantOnlyNullish);
expectType<'foo..baz..xyz'>(generalTestVariantNullish);

expectNotAssignable<'foo'>(generalTestVariantOnlyStrings);
expectNotAssignable<'foo.bar'>(generalTestVariantOnlyStrings);
expectNotAssignable<'foo.bar.ham'>(generalTestVariantOnlyStrings);
Expand Down Expand Up @@ -44,3 +53,18 @@ const stringArray = ['foo', 'bar', 'baz'];
const joinedStringArray: Join<typeof stringArray, ','> = '';
expectType<string>(joinedStringArray);
expectNotAssignable<'foo,bar,baz'>(joinedStringArray);

// Partial tuple shapes (rest param last).
const prefixTuple: ['prefix', ...string[]] = ['prefix', 'item1', 'item2'];
const joinedPrefixTuple: Join<typeof prefixTuple, '.'> = 'prefix.item1.item2';
expectType<`prefix.${string}`>(joinedPrefixTuple);

// Partial tuple shapes (rest param first).
const suffixTuple: [...string[], 'suffix'] = ['item1', 'item2', 'suffix'];
const joinedSuffixTuple: Join<typeof suffixTuple, '.'> = 'item1.item2.suffix';
expectType<`${string}.suffix`>(joinedSuffixTuple);

// Tuple with optional elements.
const optionalTuple: ['hello' | undefined, 'world' | undefined] = ['hello', undefined];
const joinedOptionalTuple: Join<typeof optionalTuple, '.'> = 'hello.';
expectType<'hello.'>(joinedOptionalTuple);

0 comments on commit bb81314

Please sign in to comment.