sunny-mittal
sunny-mittal

Reputation: 509

Typescript: Specify that type parameter is key of object and has a specific type

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:

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

Answers (2)

sunny-mittal
sunny-mittal

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

tenshi
tenshi

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.

Playground

Upvotes: 1

Related Questions