Initially, it is valuable to create a distinct function for establishing two-way mapping. The desired behavior resembles how numerical enums operate in TypeScript. Nonetheless, for a generic function, both arguments' lengths should be validated.
Take this example:
type StringToTuple<T extends string> =
T extends `${infer Left}${infer Right}`
? [Left, ...StringToTuple<Right>]
: [];
// checks if number is a 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 ensure
* that the length is a literal number and not just a "number" type
* If it's a "number" type instead of "5" or "9", how can we 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) // works
twoWayMap('a', 'aa') // fails
Next, we need to determine the return type by combining two strings using the Zip
method to create a two-way dictionary. It's unnecessary to construct two-way binding in one utility type; let's focus on one-way binding only.
type List = ReadonlyArray<PropertyKey>
// unnecessary array methods such as forEach, map, concat, etc.
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 _ = Merge<['a'], ['b']> // { a: "b" };
type __ = Merge<['a', 'b'], ['c', 'd']> // { a: "c", b:"d" };
}
Now, creating a two-way binding becomes simple; just call Merge
with reversed 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"
}
Complete 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 explore more about function argument type validation in my articles here and here.
In the above example, I employed overloading for return type inference.
If you're not fond of overloads, you can stick with this simpler version:
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>)
This method is perfectly valid. When using reduce
, there is no other way to infer the return type of a function without employing as
type assertion.