diff --git a/index.d.ts b/index.d.ts index aa48c653d..ca6b0c3d0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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'; @@ -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'; diff --git a/readme.md b/readme.md index c3006aff5..c86a9a478 100644 --- a/readme.md +++ b/readme.md @@ -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. @@ -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 diff --git a/source/join-union.d.ts b/source/join-union.d.ts new file mode 100644 index 000000000..5c268fdc4 --- /dev/null +++ b/source/join-union.d.ts @@ -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; +//=> '' +``` + +@see {@link Join} +@category Union +@category Template literal +*/ +export type JoinUnion< + Items extends JoinableItem, + Delimiter extends string = ',', +> = UnionToTuple extends infer Tuple extends JoinableItem[] + ? Join + : ''; + +export {}; diff --git a/source/join.d.ts b/source/join.d.ts index ab3a963f6..9bd786b07 100644 --- a/source/join.d.ts +++ b/source/join.d.ts @@ -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 diff --git a/source/literal-list.d.ts b/source/literal-list.d.ts new file mode 100644 index 000000000..43ee04fa1 --- /dev/null +++ b/source/literal-list.d.ts @@ -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 = + `${E[0]}${ + [T] extends [readonly JoinableItem[]] // TODO: add `JoinableArray` type + ? IsUnion extends true + ? JoinUnion<`[${Join}]`, S> + : Join + : [T] extends [JoinableItem] + ? JoinUnion + : '...' // Too complex + }${E[1]}`; + +/** Stringify a tuple as `'[a, b]'` */ +type TupleAsString = TypeAsString; + +/** Stringify a union as `'(a | b)[]'` */ +type UnionAsString = TypeAsString; + +/** +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( + list: LiteralList +): 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 = + IfNotAnyOrNever['length'], + TupleAsString, + UnionAsString + > + >; + +/** +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 extends infer TnU // T not U + ? Exclude extends infer UnT // U not T + ? IsNever extends true // T includes U + ? IsNever extends true // U includes T + ? T // T is U + : never | `${UString}, Type ${TString} is missing members: ${TupleAsString}` + : never | `${UString}, Type ${TString} has extra members: ${TupleAsString}` + : never + : never + : never | `${UString}, Type ${TString} is not the required length of: ${ULength}` +); + +export {}; diff --git a/source/union-to-tuple.d.ts b/source/union-to-tuple.d.ts index d06bf8aab..bbef6ff0c 100644 --- a/source/union-to-tuple.d.ts +++ b/source/union-to-tuple.d.ts @@ -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'; /** @@ -50,9 +51,14 @@ const petList = Object.keys(pets) as UnionToTuple; @category Array */ -export type UnionToTuple> = -IsNever extends false - ? [...UnionToTuple>, L] - : []; +export type UnionToTuple = + _UnionToTuple extends infer Result extends UnknownArray + ? Result + : never; + +type _UnionToTuple> = + IsNever extends false + ? [...UnionToTuple>, L] + : []; export {}; diff --git a/test-d/join-union.ts b/test-d/join-union.ts new file mode 100644 index 000000000..6b2b5d327 --- /dev/null +++ b/test-d/join-union.ts @@ -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); +expectAssignable<''>({} as JoinUnion); +expectAssignable<','>({} as JoinUnion); +expectAssignable<'2,'>({} as JoinUnion); + +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>(''); +expectType>(''); + +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); diff --git a/test-d/literal-list.ts b/test-d/literal-list.ts new file mode 100644 index 000000000..97213ea62 --- /dev/null +++ b/test-d/literal-list.ts @@ -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 LiteralList + ? true + : false; + +// Base +expectType>(false); +expectType>(false); +expectType>(true); +expectType>(false); +expectType>(false); +expectType>(true); +expectType>(false); +expectType>(false); +expectType>({} as any); // `any` can't match +expectType>({} as never); // `never` can't match + +// Orders +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +// Unions +expectType>(true); +expectType>({} as boolean); +expectType>(false); + +// Long Unions +expectType>(true); // Match +expectType>(false); // Shorter +expectType>(false); // Extra +expectType>(false); // Missing +expectType>(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({} as I1); +expectAssignable({} as I2); +expectAssignable({} as I3); +expectAssignable({} as I4); +expectAssignable({} as I5); +expectAssignable({} as I6); +expectAssignable({} as I7); +expectAssignable({} 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({} as A1); +expectAssignable({} as A2); +expectAssignable({} as A3); +expectAssignable({} as A4); +expectAssignable({} as A5); +expectAssignable({} as A6); +expectAssignable({} as A7); +expectAssignable({} as A8);