I'm new to typescript and want to write a pretty simple generic function - it gets an object (possibly partial), gets a list of keys and maps the specified keys (if they exist) to a different value, the rest of the values need to stay the same. I want all that to be type safe.
So in case I have a type:
interface User {
userID: string;
displayName: string;
email: string;
photoURL: string;
}
I want to be able to have a function mapper
which will take an object of type Partial<User>
and then a list of keys, like "displayName" | "photoURL"
and, for instance, capitalize their property values, and leave the rest of the properties untouched.
Here are some examples of input/output:
// INPUT
const fullUser: User = {
userID: "aaa",
displayName: "bbb",
email: "[email protected]",
photoURL: "https://eee.fff",
}
const partialUser = {
userID: "ggg",
photoURL: "https://hhh.iii",
}
// OUTPUT
const o1 = capsMapper<User, "displayName" | "userID">(fullUser);
const o2 = capsMapper<User, "displayName" | "userID">(partialUser);
// o1 should be
{
userID: "AAA",
displayName: "BBB",
email: "[email protected]",
photoURL: "https://eee.fff",
}
// o2 should be
{
userID: "GGG",
photoURL: "https://hhh.iii",
}
So I wrote this simple generic function:
const mapper = <Type, Keys extends keyof Type>(data: Partial<Type>) => {
???
}
and was hoping to call it like mapper<User, "displayName" | "photoURL">(myObject)
but I cannot find a way to implement the function body. The problem is, when I'm iterating over the keys of the passed object, I cannot find a way to check if the key is amongst the Keys
type passed as a generic type.
I also tried to have an additional parameter in the function:
const mapper = <Type, Keys extends keyof Type>(data: Partial<Type>, keys: Keys) => {
or even
const mapper = <Type, Keys extends keyof Type>(data: Partial<Type>, keys: Keys[]) => {
but still checking the key of the passed (possibly partial) object results in compiler errors.
Is it possible to implement such a function?
CodePudding user response:
function mapperArray<T, FilteredProp extends keyof T>(obj: Partial<T>, keyList: Array<keyof T>) {
const a:any = {};
(new Set(keyList.filter(key => key in obj))).forEach(key => a[key] = obj[key])
return a as Pick<T, FilteredProp>;
}
const a = {
prop1: "dshfyuhe",
prop2: 154
};
const b = mapperArray<typeof a, 'prop2'>(a, ['prop2']);
console.log(b.prop2);
this is closest i could come up with. I hope this meet requirement, i also know that this can be improved further but currently i doesn't have knowledge and time.
I could not write it in comment with correct formatting so pasting here.
CodePudding user response:
You definitely need to pass in actual key string arguments into the capsMapper()
function. The TypeScript compiler is happy to deal with generic type parameters like K extends keyof T
, but the static type system, including generics, is erased when TypeScript is emitted to JavaScript, which is what actually runs at runtime. If you don't have any values of type K
, then you're probably not going to be able to do anything with them at runtime. So instead of <T, K extends keyof T>(data: T) => ...
we'll need something like <T, K extends keyof T>(data: T, ...keys: K[]) => ...
.
Here's one possible implementation of capsMapper
:
const capsMapper = <T extends Partial<Record<K, string>>, K extends keyof T>(
data: T, ...keys: K[]
) => {
const d: Omit<T, K> = data;
const mapped: { [P in K]?: string } = {};
for (const k of keys) {
const v = data[k];
if (typeof v === "string") {
mapped[k] = v.toUpperCase();
}
}
type WidenString<T> = T extends string ? string : T;
return { ...d, ...mapped } as {
[P in keyof T]: P extends K ? WidenString<T[P]> : T[P]
};
}
This might be overkill in terms of the typings, but I'll explain it. We only accept as data
an object of type T
whose keys of type K
have string
or undefined
values. Then we take data
and widen its type from T
to Omit<T, K>
... this is just saying "let's ignore any keys of data
that are in K
" because we're going to overwrite these anyway. We assign this to d
for ease of use.
Then we make an object for just the mapped properties called mapped
, whose type is essentially Partial<Record<K, string>>
, and for each k
in the keys
array, we captialize any string properties we find and put it into mapped
at the same key.
Finally we spread d
and mapped
into a new object and return it. The type of that output would be naturally seen as Omit<T, K> & Partial<Record<K, string>>
, which is correct, but not as specific as you might like. That's because every key in keys
would be seen as possibly missing, even if you know for a fact that it existed in data
.
To make it more specific, we need a type assertion to tell the compiler that it is actually of type { [P in keyof T]: P extends K ? WidenString<T[P]> : T[P] }
, a mapped type very much like T
(the type of data
) except that any mapped string properties are widened to string
via the WidenString<T>
conditional type. This is important in the case that your original T
type had properties of string literal type; if you uppercase those, you will not get the same type out.
Here let's test it and I'll show the possible need for that later:
const fullUser: User = {
userID: "aaa",
displayName: "bbb",
email: "[email protected]",
photoURL: "https://eee.fff",
}
const partialUser: Partial<User> = {
userID: "ggg",
photoURL: "https://hhh.iii",
}
const o1 = capsMapper(fullUser, "displayName", "userID");
/* const o1: {
userID: string;
displayName: string;
email: string;
photoURL: string;
} */
console.log(o1)
/* { "userID": "AAA", "displayName": "BBB",
"email": "[email protected]", "photoURL": "https://eee.fff" } */
const o2 = capsMapper(partialUser, "displayName", "userID");
/* const o2: {
userID?: string | undefined;
displayName?: string | undefined;
email?: string | undefined;
photoURL?: string | undefined;
} */
console.log(o2);
/* { "userID": "GGG", "photoURL": "https://hhh.iii" } */
That looks reasonable, I think. The output looks right, and the types are the same... o1
is still a User
, and o2
is still a Partial<User>
. In the case where we have literal types, though, this happens:
interface Foo {
a?: "x" | "y"
}
const foo: Foo = { a: "x" };
const o3 = capsMapper(foo, "a");
/* const o3: {
a?: string | undefined; // this is not "x" | "y" anymore, so o3 is not a Foo
} */
console.log(o3)
// {"a": "X"}
Here o3
has an a
property which is "X"
in uppercase. That means o3
is not a valid Foo
, whose a
property can only be "x"
or "y"
in lowercase. And that's why I have that WidenString<T>
type in there. It turns the input type {a?: "x" | "y"}
into the output type {a?: string}
.
If you don't expect this kind of thing to happen, you could make the typing simpler by writing instead:
return { ...d, ...mapped } as T;
and then o3
would be claimed by the compiler to be of type Foo
when it isn't.