0

I am trying to create a function that lets a caller remap an object given a specific mapping. The resulting object should be able to know the new new field names and types.

Is this possible in typescript? I think all I need at this point is a return type. This is non-working code I have:

const mapping = {
    a: "learn",
    b: "state"
}

const original = {
    a: false,
    b: 0
}

const map = <
    Mapping,
    Original
>(
    mapping: Mapping,
    states: Original
): {
    [key: Mapping[key]]: Original[key] //WHAT IS THE CORRECT TYPE HERE?
} => {
    return Object.keys(original).reduce((total, key) => {
        return {
            ...total,
            [mapping[key]]: original[key] //THERE IS AN ERROR HERE TOO BUT I AM NOT WORRIED ABOUT THAT RIGHT NOW
        }
    }, {})
}

const remapped = map(mapping, original)

console.log(remapped)
console.log(remapped.learn)
console.log(remapped.state)

I am essentially trying to rename a to learn and b to state. The code is working functionally but I am getting a type error ('key' refers to a value, but is being used as a type here.). Any help would be greatly appreciated!

Logan Murphy
  • 6,120
  • 3
  • 24
  • 42

2 Answers2

1

First you'll need @jcalz's amazing UnionToIntersection utility type in your toolbox.

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never;

Now let's make our own utility type:

1    type Remap<A extends { [k: string]: string }, B> =
2        keyof B extends keyof A ?
3        { [P in keyof B & keyof A]: { [k in A[P]]: B[P] } } extends
4        { [s: string]: infer V } ? 
5        UnionToIntersection<V>
6        : never
7        : never;

Explain:

  • ln:1, constrain A's value to be string.
  • ln:2, constrain that all B's keys present in A
  • ln:3, map to { "a": {"learn": boolean}, "b": {"state": number} }
  • ln:4, take the value part, which becomes: {"learn": boolean} | {"state": number} union
  • ln:5, apply the UnionToIntersection magic!

Put things together:

const mapping = {
    a: "learn",
    b: "state",
} as const;   // mark as `const` is necessary to infer value as string literal
              // else it'll just be string
const original = {
    a: false,
    b: 0,
};

const map = <
    M extends { [k: string]: string },
    O extends { [k: string]: any }
>(mapping: M, original: O): Remap<M, O> => {
    return Object.keys(original).reduce((total, key) => {
        return {
            ...total,
            [mapping[key]]: original[key]
        }
    }, {} as any); // as any to mute error
};

Playground


I came across another answer by jcalz, which provides a more elegant solution than mine. Check that answer for explanation. I'll append it here:

type Remap2<
    M extends { [k: string]: string },
    O extends { [P in keyof M]: any }
> = {
    [P in M[keyof M]]: O[
        { [K in keyof M]: M[K] extends P ? K : never }[keyof M]
    ]
};
hackape
  • 18,643
  • 2
  • 29
  • 57
0

What about this version:

type Remap<
M extends { [k: string]: string },
O extends { [k: string]: any }
> = {
    [P in keyof M  as M[P]]: P extends keyof O ? O[P] : never
} & Omit<O, keyof M>

Playground