Arel Lin
Arel Lin

Reputation: 918

How to define interface according to the keys provided by Map?

I'm new to Typescript.

I want extract a class called Mapper.
This mapper takes two parameters:
1. The object data we want to map 2. The Map that defines the shape of data after map

And it has methods that will return a MappedData according to what kind of mapping we wants to do.

class Mapper {
  constructor(data: Data, dataMap: Map) {

  }

  mapKey(): MappedKeyData {
    // return a mappedKeyData
  }
}

// Usage
const source = {
  Name: "Dudi"
}
const map = new Map([
  ['name', 'Name']
])
const mapper = new Mapper(source, map)
console.log(mapper.mapKey()) // {name: 'Dudi'}

The question is that I want to ensure the type of MappedKeyData is an interface that has all the keys that defined after key mapping. (according to the keys defined in the map)

For example:

interface MappedKeyData {
  name: 'string'
}

Is this the right way to use Typescript? If yes, how to implement this? Thanks...

Upvotes: 0

Views: 1006

Answers (1)

jcalz
jcalz

Reputation: 329333

If you really want to use classes and Map objects you can, but it would be more straightforward just to use a function and a plain old javascript object.
I will show one way to do that, and you can adapt it if you need to (but be warned that Map in TypeScript isn't strongly typed the way you want so I'd avoid it).

Here is the mapKeys() function:

function mapKeys<D extends object, M extends Record<keyof M, keyof D>>(
  data: D,
  dataMap: M
) {
  const ret = {} as { [K in keyof M]: D[M[K]] };
  (Object.keys(dataMap) as Array<keyof M>).forEach(
    <K extends keyof M>(k: K) => {
      ret[k] = data[dataMap[k]];
    }
  );
  return ret;
}

And here is the basic usage of it:

const mapped = mapKeys(
  {
    Name: "Dudi",
    Age: 123
  },
  { name: "Name", age: "Age" }
);
// const mapped: {
//    name: string;
//    age: number;
// }
console.log(mapped); // {name: 'Dudi', age: 123}

The way mapKeys() works at runtime is simply to loop through the keys k of dataMap and set the analogous property in the return object to be the data from data at key dataMap[k]. So that's the ret[k] = data[dataMap[k]] line.

The rest of it is mostly type annotations and assertions to help the compiler understand and verify what we are doing. Given that data is of type D and dataMap is of type M, the return type will be the mapped type {[K in keyof M]: D[M[K]] }. This is saying that it will have the same keys as M, and the value for each key K will be of type D[M[K]] (that is, we look up the K property of M, and then look up that property of D).

So that should work for you, depending on your use case.


Of course there are a bunch of caveats; I tried to ask what you wanted to see in some of these cases, and all I know for sure is that you don't want it to be possible to put a value in dataMap if it's not a key of data. The constraint M extends Record<keyof M, keyof D> should guarantee that. But there are other possible edge cases:

STRONGLY TYPED dataMap

First of all, the dataMap param has to be fairly strongly typed for this to work. If you just do this:

const source = { Name: "Didu", Age: 321 };
const map = { name: "Name", age: "Age" }; 

Then the map will end up being inferred as type {name: string, age: string} and not {name: "Name", age: "Age"}. And you'll get an error:

const badInfer = mapKeys(source, map); // error!
// ┌───────────────────────────> ~~~
// └─ Argument of type '{ name: string; age: string; }' is not assignable
//    to parameter of type 'Record<"name" | "age", "Name" | "Age">'.

The way to fix this is to make sure that dataMap has string literal property values, such as you get when you use a const assertion:

const fixedMap = { name: "Name", age: "Age" } as const; //
// inferred to be {readonly name: "Name"; readonly age: "Age"; }
const fixedInfer = mapKeys(source, fixedMap); // okay, right type now

Do note that if we used a Map instead of an object, the default TypeScript type declarations for it are not strong enough. A Map<K, V> means that potentially any key in K could map to any value in V. And so new Map([["name","Name"],["age","Age"]]) would, at best, end up equivalent to the type {name: "Name" | "Age", age: "Name" | "Age"}, and you'd have an unfortunate string | number in your output type.

LEAVING OUT PROPERTIES

Nothing stops you from leaving some of the keys in data out of dataMap:

const fewerProps = mapKeys({ Name: "Dodi", Age: 132 }, { name: "Name" });
// const fewerProps: { name: string }; // no age
console.log(fewerProps); // {name: "Dodi"}

DUPLICATED PROPERTIES

Nothing stops you from mapping the same key in data multiple times in dataMap:

const duplicateProps = mapKeys(
  { Name: "Dido", Age: 231 },
  {
    name: "Name",
    alsoName: "Name",
    age: "Age",
    alsoAge: "Age"
  }
);
// const duplicateProps: {name: string; alsoName: string; age: number; alsoAge: number;}
console.log(duplicateProps); // {name: "Dido", alsoName: "Dido", age: 231, alsoAge: 231}

INDEX SIGNATURES WILL DO WEIRD THINGS

If your data object has an index signature the type system will behave strangely, since it will assume data has any possible key that matches the index:

const dictionary: { [k: string]: string | number } = { Name: "Didi", Age: 312 };
const confused = mapKeys(dictionary, {
  dogs: "Dogs",
  cats: "Cats",
  age: "Age"
});
// const confused: { dogs: string | number; cats: string | number; age: string | number;}
console.log(confused); // {dogs: undefined, cats: undefined, age: 312 }
// note that "undefined" can sneak into index signatures

That's not a perfect outcome, since the type system says confused has a dogs property of type string | number, but it's actually undefined at runtime. This is one of the pitfalls of index signatures, and our mapper falls into it.


Some of those caveats can be worked around if you decide what you'd like to see and put in more complex type assertions and annotations, but I think I'll stop here and leave any patching-up to you. This should hopefully be enough to give you some direction. Good luck!

Link to code

Upvotes: 1

Related Questions