Reputation: 509
I've been struggling with this for hours now and am hoping someone here can help. I've scoured the internet and asked on Discord with no success. I'm trying to create a generic function that accepts an object, a key of that object that specifically holds an array value, and a new key that will be used in the transformation. A specific example of what I'm trying to do will help. I have a list of contacts of a particular shape and would like to map this to a new list of similar structures:
type PhoneNumber = {
label: string
number: string
}
type Contact = {
givenName: string
phoneNumbers: PhoneNumber[]
}
type NewContact = {
givenName: string
phoneNumber: PhoneNumber
}
const contacts: Contact[] = [ ... ]
const mappedContacts: NewContact[] = contacts.map(transform).flat()
Now I can achieve this trivially in a non-generic way:
const transform = (contact: Contact): NewContact[] => {
const { phoneNumbers, ...rest } = contact
return phoneNumbers.map(phoneNumber => ({...rest, phoneNumber })
}
but what I'm hoping for is a generic function. This obviously doesn't work, but something like this is what I've been trying:
const arrayFrom = <T extends object, K extends keyof T>(key: K, newKey: string) => (obj: T) => {
const { [key]: values, ...rest } = obj
return (values as unknown as unknown[]).map((value) => ({ ...rest, [newKey]: value }))
}
Desired usage would be something like:
const mappedContacts = contacts.map(arrayFrom('phoneNumbers', 'phoneNumber')).flat()
with the goal that mappedContacts is of type (Omit<Contact, 'phoneNumber'> & { phoneNumber: PhoneNumber })[]
but instead it is of type (Omit<Contact, 'phoneNumber'> & { [x: string]: unknown })[]
. The code works as intended, but is not type safe. The issues I know of that I can't resolve are:
arrayFrom
, ts does not know that T[K]
will be a list type, so values.map
creates an error without a cast to unknown[]newKey
is a string so of course the right side of the intersection becomes {[x: string]: unknown}
since the above issue requires the cast.Is it possible to tell typescript that the key I pass in will map to a list type so the cast is unnecessary? This would at least then result in the final type being: (Omit<Contact, 'phoneNumber'> & { [x: string]: PhoneNumber })[]
, which gets me closer. Secondly, is it possible to not have the right side be [x: string]
and instead have typescript infer the literal value? Many thanks in advance, I don't think this should be impossible in typescript but even lodash.set
seemingly couldn't figure out the second part as _.set({a: 4}, 'b', 'abc')
results in type {a: number}
.
Upvotes: 1
Views: 2535
Reputation: 509
@catgirlkelly's answer got me here so I give her credit but I modified her answer to get the correct new key added (the original re-adds the omitted key):
const arrayFrom = <T, K extends keyof T, N extends string>(key: K, newKey: N) => (obj: T) => {
const { [key]: values, ...rest } = obj;
if (!Array.isArray(values)) throw new Error("obj[key] must be an array")
return values.map((value) => ({ ...rest, [newKey]: value } as Omit<T, K> & { [_ in N]: (T[K] & unknown[])[number] }));
};
I also added the array check to avoid the casts to unknown.
Upvotes: 0
Reputation: 26307
Pretty close; I think a simple cast could do it justice:
return (
values as unknown as unknown[]
).map((value) => ({ ...rest, [newKey]: value } as Omit<T, K> & { [_ in K]: (T[K] & unknown[])[number] }));
Since you can't use computed property names in types, you have to use a mapped type here instead.
Upvotes: 1