Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I make this generic TypeScript function work as expected?

I'm attempting to define a function that works well with the type system in TypeScript, such that I can take a key of an object and, if that key's value needs some modification (converting a custom string type to a boolean here in my example), I can do that without casting the types.

Here's a TypeScript playground link that has the same walk-through but makes it easier to see the compiler errors.

Some helper types to get my example started:

type TTrueOrFalse = 'true' | 'false'
const toBool = (tf: TTrueOrFalse): boolean => tf === 'true'

We have a few fields we want to process. Some are numbers, some are checkbox-like values that we represent with TTrueOrFalse.

type TFormData = {
  yesOrNoQuestion: TTrueOrFalse
  freeformQuestion: number
}

// same keys as TFormData, but convert TTrueOrFalse to a bool instead, e.g. for a JSON API
type TSubmitFormToApi = {
  yesOrNoQuestion: boolean
  freeformQuestion: number
}

This is the function that would process one form field at a time. we have to convert the TTrueOrFalse to a boolean for this function.

const submitFormField = <FieldName extends keyof TFormData>(
    fieldName: FieldName,
    value: TSubmitFormToApi[FieldName]
) => { /* some code here */}

Here's the problem. This function should take one form field and it's value and send it off to the API, by first adjusting TTrueOrFalse values to booleans.

const handleSubmit = <
    FieldName extends keyof TFormData
  >(
    fieldName: FieldName,
    value: TFormData[FieldName]
) => {
  // I want to convert `TTrueOrFalse` to a `bool` for my API, so I check if we are dealing with that field or not.
  // seems like this check should convince the compiler that the generic type `FieldName` is now `'yesOrNoQuestion'` and
  // that `value` must be `TFormData['yesOrNoQuestion']`, which is `TTrueOrFalse`.
  if (fieldName === 'yesOrNoQuestion') {

    // `value` should be interpreted as type `TTrueOrFalse` since we've confirmed `fieldName === 'yesOrNoQuestion'`, but it isn't
    submitFormField(
      fieldName,
      toBool(value) // type error
    )

    // Looks like the compiler doesn't believe `FieldName` has been narrowed down to `'yesOrNoQuestion'`
    // since even this cast doesn't work:
    submitFormField(
      fieldName,
      toBool(value as TTrueOrFalse) // type error
    )

    // so I'm forced to do this, which "works":
    submitFormField(
      fieldName as 'yesOrNoQuestion',
      toBool(value as TTrueOrFalse)
    )
  }

  // so I thought maybe I can use a manual type checking function, but it seems like
  // the fact that `FieldName` is a union of possible strings is somehow making what I want
  // to do here difficult?
  const isYesOrNo = (fn: FieldName): fn is 'yesOrNoQuestion' => fieldName === 'yesOrNoQuestion'

  // not referencing the generic type from the function, FieldName, works here though:
  const isYesOrNoV2 = (fn: Extract<keyof TFormData, string>): fn is 'yesOrNoQuestion' => fieldName === 'yesOrNoQuestion'

  // ok, so let's try again.
  if (isYesOrNoV2(fieldName)) {
    // seems like now the compiler believes FieldName is narrowed, but that doesn't narrow
    // the subsequent type I defined for `value`: `TFormData[FieldName]`
    submitFormField(
      fieldName,
      toBool(value) // type error
    )

    // At least this seems to work now, but it still sucks:
    submitFormField(
      fieldName,
      toBool(value as TTrueOrFalse)
    )
  }
}

Note that, though the interior of handleSubmit has issues with what I'm trying to do, the compiler understands what I want it to do from the calling perspective at least:

handleSubmit('yesOrNoQuestion', 'false')
handleSubmit('yesOrNoQuestion', 'true')
handleSubmit('yesOrNoQuestion', 'should error') // fails as expected

handleSubmit('freeformQuestion', 'not a number') // fails as expected
handleSubmit('freeformQuestion', 32) 

handleSubmit('errorQuestion', 'should error') // fails as expected
handleSubmit('errorQuestion', 12) // fails as expected

Through all of this, I've come to assume that part of the problem is that something I pass into handleSubmit for fieldName could still be of the union type 'yesOrNoQuestion' | 'freeformQuestion' like this:

// (simulate not knowing the fieldName at compile time)
const unknownFieldName: Extract<keyof TFormData, string> = new Date().getDay() % 2 === 0 ? 'yesOrNoQuestion' : 'freeformQuestion'

// now these compile, problematically, because the expected value is of type `'true' | 'false' | number`
// but I don't want this to be possible.
handleSubmit(unknownFieldName, 2)

Ideally, the only way I could call handleSubmit dynamically would be by mapping over an object of type TFormData and calling handleSubmit with each key/value pair that would be known to be the correct type by the compiler.

What I really want to define for handleSubmit is a function that takes exactly one key of TFormData and a value of the key's corresponding value type. I don't want to define something that is allowed to take a union type for the fieldName but I don't know if that's possible?

I thought maybe function overloading could help, though it'd be a pain to define this for a longer form type:

function handleSubmitOverload(fieldName: 'yesOrNoQuestion', value: TTrueOrFalse): void
function handleSubmitOverload(fieldName: 'freeformQuestion', value: number): void
function handleSubmitOverload<FieldName extends keyof TFormData>(fieldName: FieldName, value: TFormData[FieldName]): void {
  if (fieldName === 'yesOrNoQuestion') {

    // This still doesn't work, same problem inside the overloaded function since the 
    // concrete implementation's parameter types have to be the same as the non-overloaded try above
    submitFormField(
      fieldName,
      toBool(value) // type error
    )
  }
}

// still works from the outside:
handleSubmitOverload('yesOrNoQuestion', 'false')
handleSubmitOverload('yesOrNoQuestion', 'wont work') // fails as expected

// At least the overloaded version does handle this other problem with our first attempt,
// since it no longer accepts the union of value types when the field name's type is not specific enough
handleSubmitOverload(unknownFieldName, 'false') // error, no matching overload
handleSubmitOverload(unknownFieldName, 42) // error, no matching overload

Is there a way to define handleSubmit in a way that achieves type safety, inside the function and out, without casting?

Edit: I think it's worth noting that I know something like this will work:

const handleSubmitForWholeForm = (
  formField: keyof TFormData,
  form: TFormData
) => {
  if (formField === 'yesOrNoQuestion') {
    submitFormField(formField, toBool(form[formField]))
  }
}

but this is not how the real code I'm basing this question off of is structured.

like image 936
manroe Avatar asked Dec 22 '22 21:12

manroe


1 Answers

TypeScript doesn't yet know how to narrow type parameters via control flow analysis (see microsoft/TypeScript#24085) . Meaning that if you make your handleSubmit() function generic in the field name type N, checking the value of fieldName will not narrow N itself, and thus the type of TFormData[N] will also not be narrowed.

One possible way to proceed is to make the function concrete and not generic. But how can we keep the fieldName and the value parameters correlated? We can use rest parameter tuples. Specifically, if we make a type AllParams defined like this:

type AllParams = { [N in keyof TFormData]: [N, TFormData[N]] }[keyof TFormData]
// type AllParams = ["yesOrNoQuestion", TTrueOrFalse] | ["freeformQuestion", number]

then we can make the signature of handleSubmit something like (...nv: AllParams) => void. AllParams is a union of all the acceptable pairs of fieldName and value (and the definition above should scale with longer forms).

Here's the handleSubmit() implementation:

const handleSubmit = (...nv: AllParams) => {
  if (nv[0] === "yesOrNoQuestion") {
    submitFormField(nv[0], toBool(nv[1]));
  } else {
    submitFormField(nv[0], nv[1]);
  }
}

You can't destructure nv into separate fieldName and value variables, or the correlation between them will be lost. Instead you have to use nv[0] and nv[1] and rely on control flow analysis to narrow nv based on testing nv[0], as shown above.

This function should work like your overloaded one in that it only accept pairs of parameters of the right type and will not accept unions-of-field-names:

handleSubmit('yesOrNoQuestion', 'false') // okay
handleSubmit('yesOrNoQuestion', 'wont work') // error
handleSubmit('freeformQuestion', 3); // okay
handleSubmit(Math.random() < 0.5 ? 'yesOrNoQuestion' : 'freeformQuestion', 1); // error

That being said, the way I usually go about dealing with correlated types passed to functions is with some judicious type assertions, as you found yourself doing inside your original handleSubmit() implementation. If you prefer the convenience of having a non-rest-parameter function signature, you could just use toBool(value as any as TTrueOrFlase) and move on.


Okay, hope that helps; good luck!

Link to code

like image 93
jcalz Avatar answered Jan 19 '23 00:01

jcalz