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
Playground
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.
MyMap?