I'm trying to work out how to use generics to convert a date which is split into multiple parts into a Date object.
So far, I have this:
export const convertDate = <T, K extends keyof T>(obj: T, key: K) => {
const k = String(key)
const [month, day, year] = [obj[`${k}-month`], obj[`${k}-day`], obj[`${k}-year`]]
if (month && day && year) {
obj[key] = new Date(year, month, day)
}
return obj
}
Which I'd like to use like so:
interface MyObjectWithADate {
date?: Date
['date-year']: string
['date-month']: string
['date-day']: string
}
const obj: MyObjectWithADate = {
'date-year': '2022',
'date-month': '12',
'date-day': '11',
}
convertDate(obj, 'date')
# obj.date = new Date(2022, 12, 11)
However, the compiler gives me the error Type 'Date' is not assignable to type 'T[K]'.
How do I ensure my object can recieve a type of Date
?
Playground link is below:
The main problem with your version of convertDate
is that it is generic in the type T
of obj
, but T
isn't known to have a Date
-valued property at key K
(we know K extends keyof T
, so T
has some property at K
, but it could be of any type whatsoever). Furthermore, T
isn't known to have keys at `${K}-year`
, `${K}-month`
, or `${K}-day`
, so you can't safely index into obj
with those keys.
If you know K
is the type of the key
parameter, then we can express the type of obj
in terms of it without needing to have another generic type parameter. It looks something like this:
type DateHavingObj<K extends string | number> =
{ [P in `${K}-${"year" | "month" | "day"}`]: string } &
{ [P in K]?: Date };
That's an intersection of two mapped types. First we have an object type whose keys are template literal types you get when you concatenate K
to "-year"
, "-month"
, or "-day"
, and whose property values are string
s. And then we have an object type with an optional property whose key is K
and whose value is Date
.
Now the call signature is like
const convertDate = <K extends string | number>(
obj: DateHavingObj<K>, key: K
) => { }
And we can see that it works when you call it on your MyObjectWithADate
-typed obj
if key
is "date"
:
convertDate(obj, "date"); // okay
but fails if you call it with some other key
:
convertDate(obj, "fake"); // error!
// -------> ~~~
/* Type 'MyObjectWithADate' is missing properties
"fake-year", "fake-month", "fake-day" */
Anyway, we need to tweak the implementation of convertDate()
a bit to make it compile with no errors:
const convertDate = <K extends string | number>(
obj: DateHavingObj<K>, key: K
) => {
const [month, day, year] = [
Number(obj[`${key}-month`]),
Number(obj[`${key}-day`]),
Number(obj[`${key}-year`])
];
const o: { [P in K]?: Date } = obj;
if (month && day && year) {
o[key] = new Date(year, month, day)
}
return obj;
}
The changes I made:
We don't need to write String(key)
if we're just going to use the result inside a template literal string. And the compiler doesn't understand that String(key)
results in a value of type `${K}`
, but it does understand that the `${key}`
results in a value of that type. So we might as well use key
directly in the template literal strings.
The Date
constructor takes year/month/day number
s as input, not string
s. So we need to convert the values to number
via Number()
(or via unary +
, or something).
Since DateHavingObject<K>
is an intersection of two generic types, and the compiler doesn't like assigning to the K
property. In order to prevent an error, we (mostly safely) upcast obj
from DateHavingObject<K>
to just the {[P in K]?: Date}
part, and then do the assignment the K
property of that.
And let's make sure it still works:
console.log(obj.date?.toUTCString()) // "Wed, 11 Jan 2023 06:00:00 GMT"
Looks good.