'How to generically map over values of object in TypeScript (where object keys are a string union)?
Suppose I have two types:
type Credentials = {
Username: string;
Password: string;
};
type XmlCredentials = { [k in keyof Credentials]: { _text: string } };
and I want to convert from Credentials
to XmlCredentials
, wrapping string
values of input properties with { _text: _ }
object.
I can easily do this manually:
const convertNonGenericManual = (input: Credentials): XmlCredentials => ({
Username: {
_text: input.Username,
},
Password: {
_text: input.Password,
},
});
but this gets cumbersome and repetitive when input types has many properties.
I tried writing the function without repetition:
const convertNonGenericManual = (input: Credentials): XmlCredentials => {
// TODO this does not type check
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { _text: value }]));
};
And even generically:
const convertGeneric = <T extends readonly string[]>(input: { [k in T[number]]: string }): { [k in T[number]]: { _text: string }; } => {
// TODO this does not type check
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { _text: value }]));
};
But I was unable to get it to type-check in either case.
Is it possible to achieve this without writing boilerplate code?
Edit:
I think that the problem reduces to being able to iterate over a tuple and having the narrow type in each iteration, which I imagine might not be possible:
const values = ["k1", "k2"] as const;
for (const v of values) {
// we'd need `v` to have type `"v1"` during first iteration and `"v2"` during second iteration - probably impossible
}
Solution 1:[1]
It's not really possible to do this in a way that the TypeScript compiler will verify as type-safe. The easiest thing you can do, by far, is to use a type assertion to just tell the compiler that you're doing the right thing:
const convertNonGenericManual = (input: Credentials): XmlCredentials => {
return Object.fromEntries(
Object.entries(input).map(([key, value]) => [key, { _text: value }])
) as XmlCredentials; // okay, no compiler error
};
That as XmlCredentials
at the end is you asserting that the output of Object.fromEntries()
will be a valid XmlCredentials
. The compiler believes you and lets you move on without complaint. This shifts responsibility for verifying type safety away from the compiler (which can't figure it out) and onto you (who hopefully can). That means you should double and triple check your code for errors before using an assertion. If you make a mistake, the compiler might not catch it:
const convertNonGenericManualOops = (input: Credentials): XmlCredentials => {
return Object.fromEntries(
Object.entries(input).map(([key, value]) => ["key", { _text: value }])
) as XmlCredentials; // still okay, not compiler error
};
Oops, this produces an object with one key named "key"
instead of a valid XmlCredentials
. So be careful.
Why can't the compiler verify type safety in your implementation?
The primary reason: the TypeScript typings for the Object.entries()
method and the similar Object.keys()
method are less specific than you might hope for. Object types in TypeScript are extendible and not sealed. You could have a value of type Credentials
that has more than just the Username
and Password
properties. This is valid TypeScript:
interface CredentialsWithCheese extends Credentials {
cheese: true
}
const credentialsWithCheese: CredentialsWithCheese = {
Username: "ghi",
Password: "jkl",
cheese: true
}
const alsoCredentials: Credentials = credentialsWithCheese;
Every CredentialsWithCheese
is also a Credentials
. So TypeScript only knows that Object.entries(input)
produces key-value tuples with keys of type string
, since it cannot restrict the keys to just "Username"
or "Password"
. There might be "cheese"
or anything else in there. So the resulting object will only be known to have string
keys too. Which is too wide for XmlCredentials
.
See this SO question for an authoritative description of this issue.
So you'd be better off using an array of only those keys you care about, like ["Username", "Password"] as const
, using a const
assertion to tell the compiler to keep track of the literal types of those keys (and not just string[]
).
That still doesn't fix things, though; the language has no way to track that the result of the output of the map()
method or the behavior of the forEach()
method is exhaustive of the XmlCredentials
properties. The best you can do is get the compiler to see that you have a Partial<XmlCredentials>
(using the Partial<T>
utility type to be a version of T
with all optional properties):
const convertNonGenericManual = (input: Credentials): XmlCredentials => {
const ret: Partial<XmlCredentials> = {};
(["Username", "Password"] as const).forEach(k => ret[k] = { _text: input[k] });
return ret as XmlCredentials;
}
And so at the end, you still have to assert that your Partial<XmlCredentials>
is an actual XmlCredentials
. See this SO question for more information about the inability of the compiler to verify that an iterative process to build an object is exhaustive.
So the above refactoring is a little more type safe, but still you are ultimately asserting things the compiler can't see. It's up to you whether the refactoring is worth it or not.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | jcalz |