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.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With