1

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

playground link

1

2 Answers 2

1

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.

Sign up to request clarification or add additional context in comments.

6 Comments

...validation part is excellent, this approach makes both type and run-time safety.
However, there are two doubts I still concern. The first one is what's the point of function overloading here, moving the ...validation part to twoWayMap implementation still works. The second is that it seems like using as in mapped type can only work with generic parameter, my TempMap instantly worked as I changed CharsTuple and NumbersTuple to two generic parameters
@crazyones110 I have using an overloading ,ainly for return type and not for ...validation. As you might have noticed, result['a'] returns a q. You can skip overloading part and just use as type assertion. This is the matter of preference. However, I think overloading is a bit safer approach because it is bivariant to function whereas any is not.
@crazyones110 I made small update
Got it, very enlightened blog!
|
1

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>;

Playground

1 Comment

Iterating simultaneously is way more smarter than my approach, thanks

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.