-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Is your feature request related to a problem? Please describe.
Imagine editing an HTML form.
<input type="text"/>
will have a value of''
if it's not filled out, there's nonull
orundefined
.- GraphQL backend accepts this field as either length > 2 or null or not present. If length < 3 then return error.
In my UI, which is react, I make my forms controlled, i.e. I keep the values of input elements in variables which are in sync (in react state). However, I don't have to represent those variables in the same way as HTML does, I am trying to basically keep the same type of object as the one backend accepts. That type is generated by graphql-codegen. I am also using a schema library (Effect) to map HTML state to react state, where I describe that this field is either missing or it's a string with length > 3. So, I am not allowing neither null
nor undefined
here, which is accurate representation of what the backend wants.
I don't want to needlessly send null
, I want to clearly model this, which is the philosophy of the schema library I'm using. Here's how I'm setting it up:
- html provides either
undefined
or''
or the actual string.undefined
comes from my own design where a section of the form is not rendered until a certain option is selected, otherwise all string fields are nevernull
orundefined
, if they aren't filled then they are''
. - I convert both
undefined
and''
into "nothing", meaning the resulting object won't have a key for that property. - I validate the string length and throw a parse error if too small, otherwise I let it through.
The result is that I have an object like { myField?: string }
while the generated mutation input type will be { myField?: InputMaybe<Scalars['String']['input']> }
where InputMaybe<T>
is T | null
but my object doesn't allow null
. My object technically still allows undefined
but that's the default typescript behavior for missing value as well (the schema library suggests to use exactOptionalPropertyTypes: true
in tsconfig
but that's going too far for me, so I'm fine with undefined
).
However, there's no issue when I actually assign my object to the mutation variables because the mutation input is a superset.
So, ANYWAY, my point is that if I could fix the diff, then I could put the input type into the schema generic and have nice auto-completion while filling out the schema and the form. I'm also using tanstack form which is also type-safe, so would be nice having full type-safe loop form data -> schema output -> graphql mutation input.
And an extra argument for this, Effect is growing in popularity and they got some opinions on nulls like that. It's mostly been used on the backend but now they've started working on effect-rx for reactive state (check this out from literally 3 days ago). Leading (?) local-first (definitely going to be very big) framework "livestore" recommends using effect too.
Describe the solution you'd like
expose inputMaybeValue
config. can also expose maybeValue
Or change the default to T | undefined
. But that's breaking though. IMO undefined
is what it should be, because in typescript it semantically means this thing is not defined/missing, while null
is for intentional actions where you specifically want to mute something. There are similar concepts with never/any/unknown
.
Describe alternatives you've considered
I wrote a utility function called schemaFor
that takes out keyof MyMutationInput
and puts them into the schema's generic so that I at least have instant fields autocomplete. But I can't navigate to the input itself from schema and I can't validate the values right away, I'll only get a big typing error when trying to assign to mutation variables.
Any additional important details?
const OptionalNonEmptyStringFormField = S.optionalToOptional(S.String, S.String.pipe(S.minLength(3)), {
decode: input => Option.flatMap(input, s => (s === '' ? Option.none() : Option.some(s))),
encode: output => (Option.isNone(output) ? Option.some('') : output)
})
export function schemaFor<A extends Record<string, any>>() {
return <F extends Record<keyof A, Struct.Field>>(fields: F) => {
// context is wrongly typed to something when it's actually `never`,
// so we have to re-cast the type to itself but without the context.
const schema = S.Struct(fields) as unknown as S.Schema<S.Simplify<Struct.Type<F>>, S.Simplify<Struct.Encoded<F>>>
return S.standardSchemaV1(schema)
}
}
const CreateItemSchema = schemaFor<CreateItemInput>()({
name: OptionalNonEmptyStringFormField
})
...
const form = useAppForm({
defaultValues: {
name: '',
// there might be a cleaner way with tanstack form, I haven't got to that part yet,
// main point is schemaFor<CreateItemInput> above, this is just temp code for now.
// also, the schema is capable of generating defaults, haven't tried..
} satisfies S.Schema.Encoded<typeof CreateItemSchema>,
onSubmit: ({ value }) => {
startTransition(async () => {
const { errors } = await createItem({
variables: {
input: S.decodeSync(CreateItemSchema)(value)
}
})
if (!errors) setOpen(false)
})
}
})