Skip to content

fix(Form): input and output type inference #3938

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 34 additions & 28 deletions src/runtime/components/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@
import type { DeepReadonly } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId } from '../types/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId, InferInput, InferOutput } from '../types/form'
import type { ComponentConfig } from '../types/utils'

type FormConfig = ComponentConfig<typeof theme, AppConfig, 'form'>

export interface FormProps<T extends object> {
export interface FormProps<S extends FormSchema> {
id?: string | number
/** Schema to validate the form state. Supports Standard Schema objects, Yup, Joi, and Superstructs. */
schema?: FormSchema<T>
schema?: S
/** An object representing the current state of the form. */
state: Partial<T>
state: Partial<InferInput<S>>
/**
* Custom validation function to validate the form state.
* @param state - The current state of the form.
* @returns A promise that resolves to an array of FormError objects, or an array of FormError objects directly.
*/
validate?: (state: Partial<T>) => Promise<FormError[]> | FormError[]
validate?: (state: Partial<InferInput<S>>) => Promise<FormError[]> | FormError[]
/**
* The list of input events that trigger the form validation.
* @defaultValue `['blur', 'change', 'input']`
Expand Down Expand Up @@ -50,11 +50,11 @@ export interface FormProps<T extends object> {
*/
loadingAuto?: boolean
class?: any
onSubmit?: ((event: FormSubmitEvent<T>) => void | Promise<void>) | (() => void | Promise<void>)
onSubmit?: ((event: FormSubmitEvent<InferOutput<S>>) => void | Promise<void>) | (() => void | Promise<void>)
}

export interface FormEmits<T extends object> {
(e: 'submit', payload: FormSubmitEvent<T>): void
export interface FormEmits<S extends FormSchema> {
(e: 'submit', payload: FormSubmitEvent<InferOutput<S>>): void
(e: 'error', payload: FormErrorEvent): void
}

Expand All @@ -63,7 +63,7 @@ export interface FormSlots {
}
</script>

<script lang="ts" setup generic="T extends object">
<script lang="ts" setup generic="S extends FormSchema">
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
import { useEventBus } from '@vueuse/core'
import { useAppConfig } from '#imports'
Expand All @@ -72,7 +72,10 @@ import { tv } from '../utils/tv'
import { validateSchema } from '../utils/form'
import { FormValidationException } from '../types/form'

const props = withDefaults(defineProps<FormProps<T>>(), {
type I = InferInput<S>
type O = InferOutput<S>

const props = withDefaults(defineProps<FormProps<S>>(), {
validateOn() {
return ['input', 'blur', 'change'] as FormInputEvents[]
},
Expand All @@ -82,7 +85,7 @@ const props = withDefaults(defineProps<FormProps<T>>(), {
loadingAuto: true
})

const emits = defineEmits<FormEmits<T>>()
const emits = defineEmits<FormEmits<S>>()
defineSlots<FormSlots>()

const appConfig = useAppConfig() as FormConfig['AppConfig']
Expand All @@ -91,7 +94,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.form || {})

const formId = props.id ?? useId() as string

const bus = useEventBus<FormEvent<T>>(`form-${formId}`)
const bus = useEventBus<FormEvent<I>>(`form-${formId}`)
const parentBus = props.attach && inject(
formBusInjectionKey,
undefined
Expand Down Expand Up @@ -149,12 +152,12 @@ onUnmounted(() => {
const errors = ref<FormErrorWithId[]>([])
provide('form-errors', errors)

const inputs = ref<{ [P in keyof T]?: { id?: string, pattern?: RegExp } }>({})
const inputs = ref<{ [P in keyof I]?: { id?: string, pattern?: RegExp } }>({})
provide(formInputsInjectionKey, inputs as any)

const dirtyFields = new Set<keyof T>()
const touchedFields = new Set<keyof T>()
const blurredFields = new Set<keyof T>()
const dirtyFields = new Set<keyof I>()
const touchedFields = new Set<keyof I>()
const blurredFields = new Set<keyof I>()

function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
return errs.map(err => ({
Expand All @@ -163,7 +166,7 @@ function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
}))
}

const transformedState = ref<T | null>(null)
const transformedState = ref<O | null>(null)

async function getErrors(): Promise<FormErrorWithId[]> {
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
Expand All @@ -180,12 +183,15 @@ async function getErrors(): Promise<FormErrorWithId[]> {
return resolveErrorIds(errs)
}

async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise<T | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof T)[]
type ValidateOpts<Silent extends boolean> = { name?: keyof I | (keyof I)[], silent?: Silent, nested?: boolean, transform?: boolean }
async function _validate(opts: ValidateOpts<false>): Promise<O>
async function _validate(opts: ValidateOpts<true>): Promise<O | false>
async function _validate(opts: ValidateOpts<boolean> = { silent: false, nested: true, transform: false }): Promise<O | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof O)[]

const nestedValidatePromises = !names && opts.nested
? Array.from(nestedForms.value.values()).map(
({ validate }) => validate(opts).then(() => undefined).catch((error: Error) => {
({ validate }) => validate(opts as any).then(() => undefined).catch((error: Error) => {
if (!(error instanceof FormValidationException)) {
throw error
}
Expand Down Expand Up @@ -221,7 +227,7 @@ async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean,
Object.assign(props.state, transformedState.value)
}

return props.state as T
return props.state as O
}

const loading = ref(false)
Expand All @@ -230,7 +236,7 @@ provide(formLoadingInjectionKey, readonly(loading))
async function onSubmitWrapper(payload: Event) {
loading.value = props.loadingAuto && true

const event = payload as FormSubmitEvent<any>
const event = payload as FormSubmitEvent<O>

try {
event.data = await _validate({ nested: true, transform: props.transform })
Expand Down Expand Up @@ -259,11 +265,11 @@ provide(formOptionsInjectionKey, computed(() => ({
validateOnInputDelay: props.validateOnInputDelay
})))

defineExpose<Form<T>>({
defineExpose<Form<I>>({
validate: _validate,
errors,

setErrors(errs: FormError[], name?: keyof T) {
setErrors(errs: FormError[], name?: keyof I) {
if (name) {
errors.value = errors.value
.filter(error => error.name !== name)
Expand All @@ -277,7 +283,7 @@ defineExpose<Form<T>>({
await onSubmitWrapper(new Event('submit'))
},

getErrors(name?: keyof T) {
getErrors(name?: keyof I) {
if (name) {
return errors.value.filter(err => err.name === name)
}
Expand All @@ -296,9 +302,9 @@ defineExpose<Form<T>>({
loading,
dirty: computed(() => !!dirtyFields.size),

dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof T>>,
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof T>>,
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof T>>
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof I>>,
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof I>>,
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof I>>
})
</script>

Expand Down
23 changes: 19 additions & 4 deletions src/runtime/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,26 @@ export interface Form<T extends object> {
blurredFields: DeepReadonly<Set<keyof T>>
}

export type FormSchema<T extends object> =
| YupObjectSchema<T>
| JoiSchema<T>
export type FormSchema<I extends object = object, O extends object = I> =
| YupObjectSchema<I>
| JoiSchema<I>
| SuperstructSchema<any, any>
| StandardSchemaV1
| StandardSchemaV1<I, O>

// Define a utility type to infer the input type based on the schema type
export type InferInput<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<Schema>
: Schema extends YupObjectSchema<infer I> ? I
: Schema extends JoiSchema<infer I> ? I
: Schema extends SuperstructSchema<infer I, any> ? I
: Schema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<Schema>
: never

// Define a utility type to infer the output type based on the schema type
export type InferOutput<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<Schema>
: Schema extends YupObjectSchema<infer O> ? O
: Schema extends JoiSchema<infer O> ? O
: Schema extends SuperstructSchema<infer O, any> ? O
: never

export type FormInputEvents = 'input' | 'blur' | 'change' | 'focus'

Expand Down
Loading