5

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
}
Piotr Jander
  • 157
  • 6
  • 1
    Hint: the type `Credentials["Username"]` resolves to the type `string`. When writing `k in keyof Credentials`, you can use `k` to get the corresponding type of a property value by the property key: `[k in keyof Credentials]: Credentials[k]` – Parzh from Ukraine May 11 '22 at 14:31
  • 1
    You can't do it with compiler-guaranteed safety. By far the easiest way for you to proceed is a single type assertion [like this](//tsplay.dev/mxYbxW) and move on. You can get a bit closer to type safety with a hardcoded list of keys (`Object.keys()` and `Object.entries()` do not produce strongly typed keys, which is [intentional](//stackoverflow.com/q/55012174/2887218)), like [this](//tsplay.dev/wQVMJN), but you still have to assert that at the end your `Partial` is actually an `XmlCredentials`; the compiler [can't track that](//stackoverflow.com/q/58981956/2887218). – jcalz May 11 '22 at 17:01
  • 1
    Does that address your question? If so, I can write up an answer; if not, what am I missing? – jcalz May 11 '22 at 17:02
  • Thanks @jcalz , this answers my question. Link to [this](https://stackoverflow.com/questions/55012174/why-doesnt-object-keys-return-a-keyof-type-in-typescript) was especially helpful. Feel free to post an answer and I'll accept. – Piotr Jander May 13 '22 at 07:29
  • I will do so when I get a chance but it might not be until a few hours from now. – jcalz May 13 '22 at 21:18

1 Answers1

2

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.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360