Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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 @@ -171,6 +172,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} from './source/split.d.ts';
export type {Words} 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 @@ -179,6 +179,7 @@ Click the type names for complete docs.
- [`And`](source/and.d.ts) - Returns a boolean for whether two given types are both true.
- [`Or`](source/or.d.ts) - Returns a boolean for whether either of two given types are 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 All @@ -205,6 +206,7 @@ Click the type names for complete docs.
- [`IsUppercase`](source/is-uppercase.d.ts) - Returns a boolean for whether the given string literal is uppercase.
- [`IsOptional`](source/is-optional.d.ts) - Returns a boolean for whether the given type includes `undefined`.
- [`IsNullable`](source/is-nullable.d.ts) - Returns a boolean for whether the given type includes `null`.
- [`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
2 changes: 1 addition & 1 deletion source/internal/type.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ type C = IfNotAnyOrNever<never, 'VALID', 'IS_ANY', 'IS_NEVER'>;
export type IfNotAnyOrNever<T, IfNotAnyOrNever, IfAny = any, IfNever = never> =
If<IsAny<T>, IfAny, If<IsNever<T>, IfNever, IfNotAnyOrNever>>;

/*
/**
Indicates the value of `exactOptionalPropertyTypes` compiler option.
*/
export type IsExactOptionalPropertyTypesEnabled = [(string | undefined)?] extends [string?]
Expand Down
35 changes: 35 additions & 0 deletions source/join-union.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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 `,`.

@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 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>
: '';
152 changes: 152 additions & 0 deletions source/literal-list.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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 {BuildTuple} from './internal/tuple.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';

/**
Create a tuple where each element is the union `U`, with the length equal to the number of members in `U`.

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

type T2 = TupleOfUnions<1 | 2 | 3>;
//=> [1 | 2 | 3, 1 | 2 | 3, 1 | 2 | 3]
```
*/
type TupleOfUnions<U> = UnionToTuple<U>['length'] extends infer Length extends number
? BuildTuple<Length, U>
: never;

/**
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 for `JoinableItem[]` mixed with `JoinableItem`
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, TupleOfUnions<Shape>, 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 extends UnknownArray,
TString extends string,
UString extends string,
> = (
T['length'] extends U['length'] // U.length != number, T always finite
? Exclude<T[number], U[number]> extends infer TnU // T not U
? Exclude<U[number], T[number]> extends infer UnT // U not T
? IsNever<TnU> extends true // T includes U
? IsNever<UnT> extends true // U includes T
? T // T == 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: ${U['length']}`
);
23 changes: 23 additions & 0 deletions test-d/join-union.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {expectAssignable, expectType} from 'tsd';
import type {JoinUnion} from '../index.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<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 '>);
101 changes: 101 additions & 0 deletions test-d/literal-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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'];

// ? Should we add this type
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); // Should match
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);