-
-
Notifications
You must be signed in to change notification settings - Fork 625
Add LiteralList
and JoinUnion
types
#1159
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
Open
benzaria
wants to merge
22
commits into
sindresorhus:main
Choose a base branch
from
benzaria:LiteralList
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+318
−1
Open
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
3e327f7
Add `LiteralList` and `JoinUnion` types
benzaria cdc113c
Fix: test errors
benzaria fdbc3e5
Merge branch 'main' into LiteralList
benzaria 452e4fc
reverte changes on `Join`
benzaria d90091b
Add: `JoinUnion` tests, docs
benzaria b60ed09
Improved: `LiteralList`
benzaria 4072386
doc: adding documentation and public exports
benzaria bb8e680
doc: fix wrong examples
benzaria d7a6def
doc: fix wrong examples
benzaria 77d29a9
doc: improve JsDoc for `JoinUnion`
benzaria f21ee18
feat: improve `TypeAsString` to support 1 depth arrays and refactor J…
benzaria ea13d94
feat: add tests covering array `Shape` union
benzaria ca44c53
doc: change description for `LiteralList`
benzaria 193fc9f
Merge branch 'main' into LiteralList
benzaria 01ae42a
revert unwanted changes on `TupleOfUnions`
benzaria 33385d4
doc: change description for `JoinUnion`
benzaria 48efb44
doc: fix typos & improve JsDoc clarity
benzaria 11ca817
Merge branch 'main' into LiteralList
benzaria 8e3127c
Merge branch 'main' into LiteralList
benzaria 750004a
feat: remove capitals from Errors
benzaria 15dab31
test: add test case for literal template & fix literalunion test
benzaria 0c6d715
Merge branch 'main' into LiteralList
benzaria File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
: ''; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
benzaria marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// ❌ 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>}` | ||
benzaria marked this conversation as resolved.
Show resolved
Hide resolved
|
||
: never | `${UString}, Type ${TString} has extra Members: ${TupleAsString<TnU>}` | ||
: never | ||
: never | ||
: never | `${UString}, Type ${TString} is not the required Length of: ${U['length']}` | ||
); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 '>); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.