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.