I am currently doing two-way string mapping;
const map = {} as MyMap; // here I want the correct type
const numbers = "0123456789abcdef" as const;
const chars = "ghijklmnopqrstuv" as const;
for (let i = 0; i < numbers.length; i) {
map[numbers[i] as GetChars<Numbers>] = chars[i] as GetChars<Chars>;
map[chars[i] as GetChars<Chars>] = numbers[i] as GetChars<Numbers>;
}
The logic is quite straightforward, but I am struggling with MyMap
type. Here is the solution I tried:
type Numbers = "0123456789abcdef";
type Chars = "ghijklmnopqrstuv";
type GetChars<T extends string> = T extends `${infer Left}${infer Right}` ? Left | GetChars<Right> : never;
type StringToTuple<T extends string> = T extends `${infer Left}${infer Right}` ? [Left, ...StringToTuple<Right>] : [];
type NumbersTuple = StringToTuple<Numbers>;
type CharsTuple = StringToTuple<Chars>;
type Index = Exclude<keyof NumbersTuple, keyof any[]>;
// TempMap is not correct
// strange! CharsTuple[P] is always a union
type TempMap = {
[P in Index as NumbersTuple[Index]]: CharsTuple[P];
};
type Reverse<T extends { [index: string]: string }> = {
[P in keyof T as T[P]]: P
};
type MyMap = TempMap & Reverse<TempMap>;
TempMap
is incorrect, I don't understand why CharsTuple[P]
is always a union
CodePudding user response:
I think the easy way is to iterate over both string simultaneously:
type Zip<
StringA extends string,
StringB extends string
> = StringA extends `${infer CharA}${infer RestA}`
? StringB extends `${infer CharB}${infer RestB}`
? { [key in CharA]: CharB } & { [key in CharB]: CharA } & Zip<RestA, RestB>
: {}
: {};
type MyMap = Zip<Numbers, Chars>;
CodePudding user response:
First of all, it worth creating a separate function to make two way mapping. This behavior you want to achieve reminds me how numerical enums works in TypeScript. However to make generic function, we should validate the length of both arguments.
Consider this example:
type StringToTuple<T extends string> =
T extends `${infer Left}${infer Right}`
? [Left, ...StringToTuple<Right>]
: [];
// checks whether number is literal type or not
type IsLiteralNumber<N extends number> =
N extends number
? number extends N
? false
: true
: false
{
type _ = IsLiteralNumber<2> // true
type __ = IsLiteralNumber<number> // false
}
/* To compare the length of both arguments, we need to make sure
* that length is a literal number and not just "number" type
* If it is a "number" type instead of "5" or "9", how we can compare it
* at all ?
*/
type IsLengthEqual<Fst extends string, Scd extends string> =
IsLiteralNumber<StringToTuple<Fst>['length']> extends true
? IsLiteralNumber<StringToTuple<Scd>['length']> extends true
? StringToTuple<Fst>['length'] extends StringToTuple<Scd>['length']
? StringToTuple<Scd>['length'] extends StringToTuple<Fst>['length']
? true
: false
: false
: false
: false
{
type _ = IsLengthEqual<'a', 'b'> // true
type __ = IsLengthEqual<'a', ''> // false
type ___ = IsLengthEqual<'', ''> // true
type ____ = IsLengthEqual<'abc', 'abc'> // true
}
const numbers = "0123456789abcdef" as const;
const chars = "ghijklmnopqrstuv" as const;
const twoWayMap = <
Hex extends string,
Chars extends string
>(
hex: Hex,
chars: Chars,
...validation: IsLengthEqual<Hex, Chars> extends true ? [] : [never]
) => { }
twoWayMap(numbers, chars) // ok
twoWayMap('a', 'aa') // error
Now we need to compute return type. In other words, we should Zip
two strings and make two way dictionary. We don't need to build two way binding in one utility type. Let's make one way binding only.
type List = ReadonlyArray<PropertyKey>
// we don't need "forEach, map, concat" etc ...
type RemoveArrayKeys<Tuple extends List> = Exclude<keyof Tuple, keyof PropertyKey[]>
type Merge<
T extends List,
U extends List
> = {
// replace array index with index value T[Prop] and use new value from U
[Prop in RemoveArrayKeys<T> as T[Prop] & PropertyKey]: U[Prop & keyof U]
}
{
type _ = Merge<['a'], ['b']> // { a: "b" };
type __ = Merge<['a', 'b'], ['c', 'd']> // { a: "c", b:"d" };
}
Now, it is easy to make two way binding, we just need to call Merge
with flipped arguments:
type Zip<
T extends List,
U extends List
> =
Merge<T, U> & Merge<U, T>
type Result = Zip<StringToTuple<'ab'>, StringToTuple<'cd'>>
{
type _ = Zip<StringToTuple<'ab'>, StringToTuple<'cd'>>['a'] // "c"
type __ = Zip<StringToTuple<'ab'>, StringToTuple<'cd'>>['c'] // "a"
}
WHole code:
const NUMBERS = "0123456789abcdef";
const CHARS = "ghijklmnopqrstuv";
type StringToTuple<T extends string> =
T extends `${infer Left}${infer Right}`
? [Left, ...StringToTuple<Right>]
: [];
type IsLiteralNumber<N extends number> =
N extends number
? number extends N
? false
: true
: false
type IsLengthEqual<Fst extends string, Scd extends string> =
IsLiteralNumber<StringToTuple<Fst>['length']> extends true
? IsLiteralNumber<StringToTuple<Scd>['length']> extends true
? StringToTuple<Fst>['length'] extends StringToTuple<Scd>['length']
? StringToTuple<Scd>['length'] extends StringToTuple<Fst>['length']
? true
: false
: false
: false
: false
type List = ReadonlyArray<PropertyKey>
type RemoveArrayKeys<Tuple extends List> = Exclude<keyof Tuple, keyof PropertyKey[]>
type Merge<
T extends List,
U extends List
> = {
[Prop in RemoveArrayKeys<T> as T[Prop] & PropertyKey]: U[Prop & keyof U]
}
type Zip<
T extends string,
U extends string
> =
& Merge<StringToTuple<T>, StringToTuple<U>>
& Merge<StringToTuple<U>, StringToTuple<T>>
type Result = Zip<'ab', 'cd'>
function twoWayMap<
Hex extends string,
Chars extends string
>(
hex: Hex,
chars: Chars,
...validation: IsLengthEqual<Hex, Chars> extends true ? [] : [never]
): Zip<Hex, Chars>
function twoWayMap<
Hex extends string,
Chars extends string
>(
hex: Hex,
chars: Chars,
) {
return hex.split('').reduce((acc, elem, index) => {
const char = chars[index]
return {
...acc,
[elem]: char,
[char]: elem
}
}, {})
}
const result = twoWayMap(NUMBERS, CHARS)
result['a'] // "q"
result["q"] // a
You can find more about function argument type validation in my articles here and here.
In above example, I have used overloading
for return type inference.
If you don't like overloadings, you can stick with this example:
const twoWayMap = <
Hex extends string,
Chars extends string
>(
hex: Hex,
chars: Chars,
) =>
hex.split('').reduce((acc, elem, index) => ({
...acc,
[elem]: chars[index],
[chars[index]]: elem
}), {} as Zip<Hex, Chars>)
It is perfectly fine. There is no other option to infer return type of a function where you use reduce
. Using as
type assertion is justified here.