befabry
befabry

Reputation: 812

Narrow down the type from a given key

I am trying to create a React Hook using TS. As arguments, I am giving the key and the value that will be assigned to this key. I would like the type of the value to be restricted by reading the given key.

export function useFormFields<T, K extends keyof T>(initialValues: T) {
  const [formFields, setFormFields] = useState<T>(initialValues)
  const createChangeHandler = (key: K, value: T[K]) => {
    setFormFields((prev: T) => ({ ...prev, [key]: value }))
  }

  return { formFields, createChangeHandler }
}

When I use the createChangeHandler function, the key part works, but it combines the types for the value.

Let's say I have an object / interface like the following :

interface User {
  name: string,
  age: number
}

I would like the crateChangeHandler to throw an error on the next example

createChangeHandler('name', 1)
//Works because the combined type are "string" | "number"
//I'd like to have "string" only

Upvotes: 0

Views: 172

Answers (2)

jcalz
jcalz

Reputation: 329258

Your problem is that you've got the wrong scope for the K generic type parameter. When a generic function is called, all its type parameters will be specified right there and then; it is not possible to defer specifying a type parameter until some later time. When you call useFormFields you know what T is but not what K will be. But with your version of the function, K will be inferred anyway. And since there's nothing there to help the compiler infer, it will be widened to its constraint, which is keyof T.

Instead you want createChangeHandler() itself to be generic in K, and so the type parameter for K should be moved to its scope:

function useFormFields<T>(initialValues: T) {
  const [formFields, setFormFields] = useState<T>(initialValues)
  const createChangeHandler = <K extends keyof T>(key: K, value: T[K]) => {
    setFormFields((prev: T) => ({ ...prev, [key]: value }))
  }

  return { formFields, createChangeHandler }
}

This gives the behavior you want:

createChangeHandler("age", 10); // okay
createChangeHandler('name', 1); // error!
// Argument of type 'number' is not assignable to parameter of type 'string'

Playground link to code

Upvotes: 2

chrisbajorin
chrisbajorin

Reputation: 6153

Your function useFormFields is generic, but the createChangeHandler function that is returned from it isn't. By the time you invoke useFormFields, from the compiler's viewpoint you've fixed those types to be a union. This is what the compiler sees:

const handler: (key: keyof User, value: User[keyof User]) => void = 
  useFormFields(someUser).createChangeHandler

You need to move the generic onto the returned function in order for the type to be narrowed at the call site of the handler:

export function useFormFields<T>(initialValues: T) {
  const [formFields, setFormFields] = useState<T>(initialValues)
  const createChangeHandler = <K extends keyof T>(key: K, value: T[K]) => {
    setFormFields((prev: T) => ({ ...prev, [key]: value }))
  }

  return { formFields, createChangeHandler }
}

now the compiler sees:

const handler: <K extends keyof User>(key: K, value: User[K]) => void = 
  useFormFields(someUser).createChangeHandler;

And it will be able to narrow the value type as you change the key input.

playground link

Upvotes: 1

Related Questions