1

I have an object that is already dynamically typed. Those types are mostly correct except one thing which is not picked up by TypeScript itself.

I iterate over the object and I convert some properties into Dates if they and with At. startAt, createdAt and so on.

This way I end up with a wrong type.

 type UserRaw = {
    name: string;
    createdAt: string;
};

const normalizeUser = function (attributes: UserRaw) {
    const normalized = { ...attributes };
    Object.entries(normalized).forEach(([key, value]) => {
        if (key.endsWith('At')) {
            (normalized as any)[key] = new Date(value as any);
        }
    });
    return normalized;
}

export type User = ReturnType<typeof normalizeUser>; // this is wrong at this point

Can I use some modern TypeScript feature and correct this? I need to map over the keys of the type and detect if the key ends with At and then say the property type is Date instead.

Martin Malinda
  • 1,573
  • 11
  • 20
  • Please provide a [mre] that clearly demonstrates the issue you are facing. Ideally someone could drop the code into a standalone IDE like [The TypeScript Playground (link here!)](https://tsplay.dev/NrvMzN) and immediately get to work solving the problem without first needing to re-create it. So there should be no typos, unrelated errors, or undeclared types or values. – jcalz Oct 04 '21 at 15:01
  • The compiler will not be able to read your code and understand the implication it has on the types, so you will have to tell it explicitly. Probably like [this](https://tsplay.dev/N9JzMm), but without a definition of `UserRaw` in your example it's hard to tell. – jcalz Oct 04 '21 at 15:06
  • heres a more complete example: https://shorturl.at/qDF38 – Martin Malinda Oct 04 '21 at 15:09
  • Your `AtKeysToDate` seems like exactly what I'm looking for! I'll experiment with it. Thank you. – Martin Malinda Oct 04 '21 at 15:11
  • That link brings me back to the code I posted. I'm not sure what you were trying to show me, but your example code should be in your question anyway; please [edit] and put a definition for `UserRaw` in there. – jcalz Oct 04 '21 at 15:13
  • 1
    sorry, not sure what happened, this should be working hopefully: https://shorturl.at/acfzB – Martin Malinda Oct 04 '21 at 15:22
  • 2
    Okay, so [this code](https://tsplay.dev/w6ByRw) works for your use cases? If so I can write up an answer. If not, could you edit failing examples into your question code? – jcalz Oct 04 '21 at 15:24
  • yes, this is sufficient! and it should work for my usecase. – Martin Malinda Oct 04 '21 at 15:29
  • I was playing around with something like `{ [P in keyof T]: P extends `${string}At` ? Date : T[P] }` - could you explain how your `infer F` works and bascially, where that `F` is coming from? – Matthias S Oct 04 '21 at 15:37
  • You could do `\`${string}At\`` also if you want, that should work too. The type `\`${string}\`` is a pattern template literal. `infer F` would grab whatever it starts with (like `"created"`) and put it into `F`, which you could use later if you want. But since you don't use it, your way is fine also. I'm happy to write up either answer. Let me know (might be a little while til I get a chunk of time to write the answer) – jcalz Oct 04 '21 at 16:03

1 Answers1

4

First let's express the output type of normalizeUser() as the result of a type function AtKeysToDate<T> which maps a type T to another type with the same keys, but whose values depend on whether or not the associated key ends in "At".

You can use template literal types to do some parsing and concatenation on string literal types. Here's one way to do it:

type AtKeysToDate<T> =
    { [K in keyof T]: K extends `${string}At` ? Date : T[K] }

The value of the property with key K is conditional depending on whether or not it is assignable to `${string}At`, a "pattern" template literal (as implemented in microsoft/TypeScript#40598) which captures the concept of "a string that ends in "At"".

You can verify that it behaves as you'd like:

type UserRaw = {
    name: string;
    createdAt: string;
};

type User = AtKeysToDate<UserRaw>;
/* type User = {
    name: string;
    createdAt: Date;
} */

Note that if you cared at all about the part of the key before the "At", you could use conditional type inference with infer to extract it:

type AtKeysToSomethingElse<T> =
    { [K in keyof T]: K extends `${infer F}At` ? F : T[K] }

type WeirdUser = AtKeysToSomethingElse<UserRaw>
/* type WeirdUser = {
    name: string;
    createdAt: "created";
} */

The behavior of K extends `${infer F}At` ? Date : T[K] and K extends `${string}At` ? Date : T[K] is pretty much the same, except that the former stores the matched part of the string in a new type parameter F, while the latter matches the string but throws away the matched part. Since you don't need the matched part, you might as well just use the pattern template literal instead of conditional type inference.


Anyway, the compiler currently cannot and probably will never be able to inspect the implementation of a function like normalizeUser() and understand that its output type is AtKeysToDate<UserRaw>. It's not even able to verify that if you tell it so. So if you want the implementation to compile, you will need to tell it the types it can't figure out, or tell it not to worry about types it can't verify, via something like type assertions:

const normalizeUser = function (attributes: UserRaw) {
    const normalized = { ...attributes } as any as User;
    Object.entries(normalized).forEach(([key, value]) => {
        if (key.endsWith('At')) {
            (normalized as any)[key] = new Date(value as any);
        }
    });
    return normalized;
}

Here we assert that normalized is of type User, so the compiler will understand that normalizeUser() returns a User. In three places I wrote as any to silence the compiler's warnings, since it cannot understand that normalized will end up being a User; nor does it understand that key will necessarily be a key of UserRaw (see Why doesn't Object.keys return a keyof type in TypeScript?), nor does it understand that value will be the right sort of argument with which one can construct a Date. Assertions abound. But as long as you're confident that the implementation does what you want, callers of normalizedUser() will have a proper strongly typed result.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • What a brilliant answer. Thank you very much! I suspected template literals could help somehow but I had no idea how to use them well. Thanks again. – Martin Malinda Oct 04 '21 at 18:30