Home > Net >  Typescript two-way string mapping
Typescript two-way string mapping

Time:12-31

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

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

Playground

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

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.

  • Related