Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3e327f7
Add `LiteralList` and `JoinUnion` types
benzaria Jun 2, 2025
cdc113c
Fix: test errors
benzaria Jun 2, 2025
fdbc3e5
Merge branch 'main' into LiteralList
benzaria Jun 6, 2025
452e4fc
reverte changes on `Join`
benzaria Jun 6, 2025
d90091b
Add: `JoinUnion` tests, docs
benzaria Jun 7, 2025
b60ed09
Improved: `LiteralList`
benzaria Jun 7, 2025
4072386
doc: adding documentation and public exports
benzaria Jun 7, 2025
bb8e680
doc: fix wrong examples
benzaria Jun 7, 2025
d7a6def
doc: fix wrong examples
benzaria Jun 7, 2025
77d29a9
doc: improve JsDoc for `JoinUnion`
benzaria Jun 9, 2025
f21ee18
feat: improve `TypeAsString` to support 1 depth arrays and refactor J…
benzaria Jun 9, 2025
ea13d94
feat: add tests covering array `Shape` union
benzaria Jun 9, 2025
ca44c53
doc: change description for `LiteralList`
benzaria Jun 9, 2025
193fc9f
Merge branch 'main' into LiteralList
benzaria Jun 9, 2025
01ae42a
revert unwanted changes on `TupleOfUnions`
benzaria Jun 9, 2025
33385d4
doc: change description for `JoinUnion`
benzaria Jun 9, 2025
48efb44
doc: fix typos & improve JsDoc clarity
benzaria Jun 10, 2025
11ca817
Merge branch 'main' into LiteralList
benzaria Jun 10, 2025
8e3127c
Merge branch 'main' into LiteralList
benzaria Jun 14, 2025
750004a
feat: remove capitals from Errors
benzaria Jun 15, 2025
15dab31
test: add test case for literal template & fix literalunion test
benzaria Jun 15, 2025
0c6d715
Merge branch 'main' into LiteralList
benzaria Jun 18, 2025
fa55556
Merge branch 'main' into LiteralList
benzaria Sep 23, 2025
f435fa5
refactor: `LiteralList` and `JoinUnion` types
benzaria Sep 23, 2025
f260b64
feat: minor changes
benzaria Oct 10, 2025
af81efe
Merge remote-tracking branch 'upstream/main' into LiteralList
benzaria Oct 10, 2025
659fb1f
fix `UnionToTuple` type output not treated as an array
benzaria Oct 10, 2025
c8d83a1
fix errors caused by new rules
benzaria Oct 10, 2025
53fecf9
revert changes on `ArrayLength`
benzaria Oct 10, 2025
2a32fdc
adding note to `JoinUnion`
benzaria Oct 11, 2025
dac0010
fixing JsDoc example on `JoinUnion`
benzaria Oct 11, 2025
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
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type {PartialOnUndefinedDeep, PartialOnUndefinedDeepOptions} from './sour
export type {UndefinedOnPartialDeep} from './source/undefined-on-partial-deep.d.ts';
export type {ReadonlyDeep} from './source/readonly-deep.d.ts';
export type {LiteralUnion} from './source/literal-union.d.ts';
export type {LiteralList} from './source/literal-list.d.ts';
export type {Promisable} from './source/promisable.d.ts';
export type {Arrayable} from './source/arrayable.d.ts';
export type {Opaque, UnwrapOpaque, Tagged, GetTagMetadata, UnwrapTagged} from './source/tagged.d.ts';
Expand Down Expand Up @@ -178,6 +179,7 @@ export type {DelimiterCase} from './source/delimiter-case.d.ts';
export type {DelimiterCasedProperties} from './source/delimiter-cased-properties.d.ts';
export type {DelimiterCasedPropertiesDeep} from './source/delimiter-cased-properties-deep.d.ts';
export type {Join} from './source/join.d.ts';
export type {JoinUnion} from './source/join-union.d.ts';
export type {Split, SplitOptions} from './source/split.d.ts';
export type {Words, WordsOptions} from './source/words.d.ts';
export type {Trim} from './source/trim.d.ts';
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Click the type names for complete docs.
- [`Or`](source/or.d.ts) - Returns a boolean for whether either of two given types are true.
- [`Xor`](source/xor.d.ts) - Returns a boolean for whether only one of two given types is true.
- [`AllExtend`](source/all-extend.d.ts) - Returns a boolean for whether every element in an array type extends another type.
- [`JoinUnion`](source/join-union.d.ts) - Join a union of strings and/or numbers using the given string as a delimiter.
- [`NonEmptyTuple`](source/non-empty-tuple.d.ts) - Matches any non-empty tuple.
- [`NonEmptyString`](source/non-empty-string.d.ts) - Matches any non-empty string.
- [`FindGlobalType`](source/find-global-type.d.ts) - Tries to find the type of a global with the given name.
Expand Down Expand Up @@ -213,6 +214,7 @@ Click the type names for complete docs.
- [`IsRequiredKeyOf`](source/is-required-key-of.d.ts) - Returns a boolean for whether the given key is a required key of type.
- [`IsReadonlyKeyOf`](source/is-readonly-key-of.d.ts) - Returns a boolean for whether the given key is a readonly key of type.
- [`IsWritableKeyOf`](source/is-writable-key-of.d.ts) - Returns a boolean for whether the given key is a writable key of type.
- [`LiteralList`](source/literal-list.d.ts) - Enforces that a tuple contains exactly the members of a union type, with no duplicates or omissions.

### JSON

Expand Down
38 changes: 38 additions & 0 deletions source/join-union.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {UnionToTuple} from './union-to-tuple.d.ts';
import type {Join, JoinableItem} from './join.d.ts';

/**
Join a union of strings and/or numbers ({@link JoinableItem `JoinableItems`}) using the given string as a delimiter.

Delimiter defaults to `,`.

Note: The order of elements is not guaranteed.
@example
```
import type {JoinUnion} from 'type-fest';

type T1 = JoinUnion<'a' | 'b' | 'c'>;
//=> 'a,b,c'

type T2 = JoinUnion<1 | 2 | 3, ' | '>;
//=> '1 | 2 | 3'

type T3 = JoinUnion<'foo'>;
//=> 'foo'

type T4 = JoinUnion<never>;
//=> ''
```

@see {@link Join}
@category Union
@category Template literal
*/
export type JoinUnion<
Items extends JoinableItem,
Delimiter extends string = ',',
> = UnionToTuple<Items> extends infer Tuple extends JoinableItem[]
? Join<Tuple, Delimiter>
: '';

export {};
10 changes: 8 additions & 2 deletions source/join.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// 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;

/**
Union of all supported types for `Join` type.

The builtin `join` method supports all these natively in the same way that typescript handles them so we can safely accept all of them.
*/
// eslint-disable-next-line type-fest/require-exported-types
export 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
Expand Down
144 changes: 144 additions & 0 deletions source/literal-list.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type {IfNotAnyOrNever} from './internal/type.d.ts';
import type {UnionToTuple} from './union-to-tuple.d.ts';
import type {UnknownArray} from './unknown-array.d.ts';
import type {Join, JoinableItem} from './join.d.ts';
import type {JoinUnion} from './join-union.d.ts';
import type {IsNever} from './is-never.d.ts';
import type {IsUnion} from './is-union.d.ts';

/**
Convert a tuple or union type into a string representation. Used for readable error messages in other types.

- `S`: **separator** between members (`default: ','`)
- `E`: **start** and **end** delimiters of the string (`default: ['', '']`)

@example
```
type T1 = TypeAsString<['a', 'b'], ', ', ['[', ']']>;
//=> '[a, b]'

type T2 = TypeAsString<'a' | 'b', ' | '>;
//=> 'a | b'
```
*/
// TODO: Make a separate `Stringify` type
type TypeAsString<T, S extends string = ',', E extends [string, string] = ['', '']> =
`${E[0]}${
[T] extends [readonly JoinableItem[]] // TODO: add `JoinableArray` type
? IsUnion<T> extends true
? JoinUnion<`[${Join<T, ', '>}]`, S>
: Join<T, S>
: [T] extends [JoinableItem]
? JoinUnion<T, S>
: '...' // Too complex
}${E[1]}`;

/** Stringify a tuple as `'[a, b]'` */
type TupleAsString<T> = TypeAsString<T, ', ', ['[', ']']>;

/** Stringify a union as `'(a | b)[]'` */
type UnionAsString<U> = TypeAsString<U, ' | ', ['(', ')[]']>;

/**
Enforces that a tuple contains exactly the members of a union type, with no duplicates or omissions.

Returns the tuple `List` if valid. Otherwise, if any constraints are violated, a descriptive error message is returned as a string literal.

#### Requirements:
- `List` **must have the same length** as the number of members in `Shape`
- Each member of `Shape` **must appear exactly once** in `List`, **No duplicates allowed**
- The **order does not matter**

#### Use Cases:
- Ensuring exhaustive lists of options (e.g., all form field names, enum variants)
- Compile-time enforcement of exact permutations without duplicates
- Defining static configuration or table headers that match an enum or union

@example
```
import type {LiteralList} from 'type-fest';

// ✅ OK
type T1 = LiteralList<['a', 'b'], 'a' | 'b'>;
//=> ['a', 'b']

// ✅ OK
type T2 = LiteralList<[2, 1], 1 | 2>;
//=> [2, 1]

// ❌ Length mismatch
type T3 = LiteralList<['a', 'b', 'c'], 'a' | 'b'>;
//=> '(a | b)[], Type [a, b, c] is not the required length of: 2'

// ❌ Missing element
type T4 = LiteralList<['a'], 'a' | 'b'>;
//=> '(a | b)[], Type [a] is missing members: [b]'

// ❌ Extra element
type T5 = LiteralList<['a', 'e'], 'a' | 'b'>;
//=> '(a | b)[], Type [a, e] has extra members: [e]'
```

@example
```
import type {LiteralList} from 'type-fest';

type Union = 'a' | 'b' | 'c';

declare function literalList<const T extends UnknownArray>(
list: LiteralList<T, Union>
): typeof list;

const C1 = literalList(['a', 'b', 'c'] as const);
//=> ['a', 'b', 'c']

const C2 = literalList(['c', 'a', 'b'] as const);
//=> ['c', 'a', 'b']

const C3 = literalList(['b', 'b', 'b'] as const); // ❌ Errors in Compiler and IDE
//=> '(a | b | c)[], Type [b, b, b] is missing members: [a, c]'
```

@category Type Guard
@category Utilities
*/
export type LiteralList<List extends UnknownArray, Shape extends UnknownArray | unknown> =
IfNotAnyOrNever<List,
_LiteralList<
List, Shape,
UnionToTuple<Shape>['length'],
TupleAsString<List>,
UnionAsString<Shape>
>
>;

/**
Internal comparison logic for {@link LiteralList `LiteralList`}.

Compares `T` and `U`:

- Validates that the lengths match.
- Then checks for extra or missing elements.
- If mismatch found, returns a readable error string.

*/
type _LiteralList<
T extends UnknownArray, U,
ULength extends number,
TString extends string,
UString extends string,
> = (
T['length'] extends ULength // U.length != number, T always finite
? Exclude<T[number], U> extends infer TnU // T not U
? Exclude<U, T[number]> extends infer UnT // U not T
? IsNever<TnU> extends true // T includes U
? IsNever<UnT> extends true // U includes T
? T // T is U
: never | `${UString}, Type ${TString} is missing members: ${TupleAsString<UnT>}`
: never | `${UString}, Type ${TString} has extra members: ${TupleAsString<TnU>}`
: never
: never
: never | `${UString}, Type ${TString} is not the required length of: ${ULength}`
);

export {};
14 changes: 10 additions & 4 deletions source/union-to-tuple.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {IsNever} from './is-never.d.ts';
import type {UnknownArray} from './unknown-array.d.ts';
import type {UnionToIntersection} from './union-to-intersection.d.ts';

/**
Expand Down Expand Up @@ -50,9 +51,14 @@ const petList = Object.keys(pets) as UnionToTuple<Pet>;

@category Array
*/
export type UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
? [...UnionToTuple<Exclude<T, L>>, L]
: [];
export type UnionToTuple<T> =
_UnionToTuple<T> extends infer Result extends UnknownArray
? Result
: never;

type _UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
? [...UnionToTuple<Exclude<T, L>>, L]
: [];

export {};
25 changes: 25 additions & 0 deletions test-d/join-union.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {expectAssignable, expectType} from 'tsd';
import type {JoinUnion} from '../source/join-union.d.ts';

expectAssignable<'a,b' | 'b,a'>({} as JoinUnion<'a' | 'b'>);
expectAssignable<'1 | 2' | '2 | 1'>({} as JoinUnion<1 | 2, ' | '>);
expectAssignable<'foo'>({} as JoinUnion<'foo'>);
expectAssignable<'42'>({} as JoinUnion<42>);

expectAssignable<''>({} as JoinUnion<null>);
expectAssignable<''>({} as JoinUnion<undefined>);
expectAssignable<','>({} as JoinUnion<undefined | null>);
expectAssignable<'2,'>({} as JoinUnion<undefined | 2>);

expectAssignable<`foo,on${string}` | `on${string},foo`>({} as JoinUnion<'foo' | `on${string}`>);
expectAssignable<`${string & {}}`>({} as JoinUnion<'foo' | (string & {})>); // Intended `foo,${string}`
// TODO: For now `UnionToTuple` does not handle 'LiteralUnions'. Will be fixed after `ExtractLiterals` type get approved.

expectType<JoinUnion<never>>('');
expectType<JoinUnion<never, ' + '>>('');

expectAssignable<'a-b' | 'b-a'>({} as JoinUnion<'a' | 'b', '-'>);
expectAssignable<'x🔥y' | 'y🔥x'>({} as JoinUnion<'x' | 'y', '🔥'>);
expectAssignable<'12' | '21'>({} as JoinUnion<1 | 2, ''>);

expectAssignable<'true or false' | 'false or true'>({} as JoinUnion<boolean, ' or '>);
100 changes: 100 additions & 0 deletions test-d/literal-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {expectAssignable, expectType} from 'tsd';
import type {LiteralList} from '../source/literal-list.d.ts';
import type {UnknownArray} from '../source/unknown-array.d.ts';

type U1 = 'a' | 'b' | 'c' | 'd';
type U2 = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type U3 = ['a'] | ['b', 'c'] | ['a', 'b'];

type IsLiteralList<T extends UnknownArray, U> =
T extends LiteralList<T, U>
? true
: false;

// Base
expectType<IsLiteralList<[], U1>>(false);
expectType<IsLiteralList<U1[], U1>>(false);
expectType<IsLiteralList<[U1, U1, U1, U1], U1>>(true);
expectType<IsLiteralList<[U1, U1, U1], U1>>(false);
expectType<IsLiteralList<[U1, U1, U1, U1, U1], U1>>(false);
expectType<IsLiteralList<[...['a', 'b', 'd', 'c']], U1>>(true);
expectType<IsLiteralList<unknown[], U1>>(false);
expectType<IsLiteralList<[unknown, unknown, unknown, unknown], U1>>(false);
expectType<LiteralList<any, U1>>({} as any); // `any` can't match
expectType<LiteralList<never, U1>>({} as never); // `never` can't match

// Orders
expectType<IsLiteralList<['a', 'b', 'c', 'd'], U1>>(true);
expectType<IsLiteralList<['a', 'b', 'd', 'c'], U1>>(true);
expectType<IsLiteralList<['a', 'c', 'b', 'd'], U1>>(true);
expectType<IsLiteralList<['a', 'c', 'd', 'b'], U1>>(true);
expectType<IsLiteralList<['a', 'd', 'b', 'c'], U1>>(true);
expectType<IsLiteralList<['a', 'd', 'c', 'b'], U1>>(true);
expectType<IsLiteralList<['b', 'a', 'c', 'd'], U1>>(true);
expectType<IsLiteralList<['b', 'a', 'd', 'c'], U1>>(true);
expectType<IsLiteralList<['b', 'c', 'a', 'd'], U1>>(true);
expectType<IsLiteralList<['b', 'c', 'd', 'a'], U1>>(true);
expectType<IsLiteralList<['b', 'd', 'a', 'c'], U1>>(true);
expectType<IsLiteralList<['b', 'd', 'c', 'a'], U1>>(true);
expectType<IsLiteralList<['c', 'a', 'b', 'd'], U1>>(true);
expectType<IsLiteralList<['c', 'a', 'd', 'b'], U1>>(true);
expectType<IsLiteralList<['c', 'b', 'a', 'd'], U1>>(true);
expectType<IsLiteralList<['c', 'b', 'd', 'a'], U1>>(true);
expectType<IsLiteralList<['c', 'd', 'a', 'b'], U1>>(true);
expectType<IsLiteralList<['c', 'd', 'b', 'a'], U1>>(true);
expectType<IsLiteralList<['d', 'a', 'b', 'c'], U1>>(true);
expectType<IsLiteralList<['d', 'a', 'c', 'b'], U1>>(true);
expectType<IsLiteralList<['d', 'b', 'a', 'c'], U1>>(true);
expectType<IsLiteralList<['d', 'b', 'c', 'a'], U1>>(true);
expectType<IsLiteralList<['d', 'c', 'a', 'b'], U1>>(true);
expectType<IsLiteralList<['d', 'c', 'b', 'a'], U1>>(true);

// Unions
expectType<IsLiteralList<['a', 'b', 'c', 'd'] | ['a', 'b', 'd', 'c'], U1>>(true);
expectType<IsLiteralList<['a', 'c', 'b', 'd'] | ['e'], U1>>({} as boolean);
expectType<IsLiteralList<['a'] | ['e'], U1>>(false);

// Long Unions
expectType<IsLiteralList<[1, 2, 3, 4, 5, 6, 7, 8, 9], U2>>(true); // Match
expectType<IsLiteralList<[1, 2, 3, 4, 5, 6, 7, 8], U2>>(false); // Shorter
expectType<IsLiteralList<[1, 2, 3, 4, 5, 6, 7, 8, 0], U2>>(false); // Extra
expectType<IsLiteralList<[1, 2, 3, 4, 5, 6, 7, 8, 8], U2>>(false); // Missing
expectType<IsLiteralList<[1, 2, 3, 4, 5, 6, 7, 8, 9, 0], U2>>(false); // Longer

// Errors for `JoinableItem` (hover to see errors)
type I1 = LiteralList<['a', 'b', 'c'], U1>;
type I2 = LiteralList<['b', 'c', 'd'], U1>;
type I3 = LiteralList<['c', 'a', 'd', 'b', 'f'], U1>;
type I4 = LiteralList<['c', 'd', 'e', 'b', 'a'], U1>;
type I5 = LiteralList<['a', 'd', 'b', 'b'], U1>;
type I6 = LiteralList<['a', 'a', 'b', 'b'], U1>;
type I7 = LiteralList<['b', 'a', 'c', 'm'], U1>;
type I8 = LiteralList<['b', 'c', 'e', 'm'], U1>;

expectAssignable<string>({} as I1);
expectAssignable<string>({} as I2);
expectAssignable<string>({} as I3);
expectAssignable<string>({} as I4);
expectAssignable<string>({} as I5);
expectAssignable<string>({} as I6);
expectAssignable<string>({} as I7);
expectAssignable<string>({} as I8);

// Errors for `JoinableItem[]` (hover to see errors)
type A1 = LiteralList<[['a'], ['b', 'c']], U3>;
type A2 = LiteralList<[['b', 'c'], ['a', 'b']], U3>;
type A3 = LiteralList<[['a'], ['b', 'c'], ['a', 'b'], ['f']], U3>;
type A4 = LiteralList<[['a'], ['b', 'c'], ['a', 'b'], 'd'], U3>;
type A5 = LiteralList<[['b', 'c'], ['a'], ['a']], U3>;
type A6 = LiteralList<[['a'], ['b', 'c'], ['b', 'c']], U3>;
type A7 = LiteralList<[['a'], ['b', 'c'], ['f']], U3>;
type A8 = LiteralList<[['b'], ['e'], ['a', 'b']], U3>;

expectAssignable<string>({} as A1);
expectAssignable<string>({} as A2);
expectAssignable<string>({} as A3);
expectAssignable<string>({} as A4);
expectAssignable<string>({} as A5);
expectAssignable<string>({} as A6);
expectAssignable<string>({} as A7);
expectAssignable<string>({} as A8);