Reputation: 918
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
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:
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.
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"}
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}
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!
Upvotes: 1