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.
CodePudding user response:
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.