Schema Validation
NotForm does not implement its own validation language.
Instead, it works with schemas that implement the Standard Schema interface. This allows the form to use validation libraries such as Zod, Valibot, ArkType, and other Standard Schema-compatible libraries without coupling the form API to one validator.
Why Standard Schema?
A form library needs a consistent way to:
- validate values
- receive validation issues
- support asynchronous validation
- understand the schema's input and output types
Standard Schema provides that common interface.
NotForm only needs to interact with the schema through its Standard Schema contract.
That means the rest of the form API stays the same regardless of which validation library you choose.
Supported Validators
NotForm can work with Standard Schema-compatible validators such as:
import { z } from 'zod'
const schema = z.object({
age: z.number().min(18, 'Must be 18 or older'),
email: z.email('Invalid email'),
})
import {
email,
minValue,
number,
object,
string,
} from 'valibot'
const schema = object({
age: number([minValue(18, 'Must be 18 or older')]),
email: string([email('Invalid email')]),
})
import { type } from 'arktype'
const schema = type({
age: 'number>=18',
email: 'email',
})
The NotForm API remains the same regardless of the validator.
Defining a Schema
Create an object schema describing the shape of your form:
import { z } from 'zod'
const schema = z.object({
username: z.string().min(
3,
'Username must be at least 3 characters',
),
email: z.email('Invalid email address',),
age: z.number().min(
18,
'Must be 18 or older',
),
bio: z.string().optional(),
})
Pass it directly to useNotForm:
const form = useNotForm({
schema,
onSubmit(values) {
console.log(values)
},
})
Schema-Driven Types
The schema is the source of truth for the form's types.
Given:
const schema = z.object({
email: z.email(),
username: z.string(),
})
NotForm can infer:
form.values.username
form.values.email
and the onSubmit callback receives the schema's validated output:
const form = useNotForm({
schema,
onSubmit(values) {
values.username
values.email
},
})
The same schema also drives field paths and typed APIs such as:
form.setValue(...)
form.validateField(...)
form.getFieldErrors(...)
form.reset(...)
Input and Output Types
Standard Schema distinguishes between the data a schema accepts and the data it produces after validation.
NotForm preserves that distinction.
Input
form.values uses:
StandardSchemaV1.InferInput<TSchema>
This is the type of data being edited in the form.
form.values
Output
onSubmit receives:
StandardSchemaV1.InferOutput<TSchema>
This is the validated result produced by the schema.
This distinction matters when the schema transforms data.
For example:
const schema = z.object({
age: z.coerce.number(),
})
The form can accept an input representation that the schema converts into a number.
The submit callback receives the validated schema output:
const form = useNotForm({
schema,
onSubmit(values) {
// validated output
console.log(values.age)
},
})
Initial Values and Schema Input
Because form.values represents schema input, initialValues is also based on the schema's input type:
const form = useNotForm({
schema,
initialValues: {
email: '',
},
onSubmit(values) {
// ...
},
})
The type of initialValues is:
DeepPartial<StandardSchemaV1.InferInput<TSchema>>
This allows nested initial state to be provided partially.
Validation
When NotForm validates, it executes the schema's Standard Schema validator against the current form values.
Conceptually:
form.values
↓
schema
↓
Standard Schema validate()
↓
┌───────────────┐
│ │
success issues
│ │
↓ ↓
value errors
A successful validation produces a value.
A failed validation produces issues.
Full Form Validation
Use:
const result = await form.validate()
A successful result:
{
value: ...
}
A failed result:
{
issues: [...]
}
The form error state is updated at the same time.
const result = await form.validate()
if ('issues' in result) {
console.log(result.issues)
}
if ('value' in result) {
console.log(result.value)
}
A successful validation clears the current error collection.
A failed validation replaces it with the new issues.
Field Validation
You can validate one field:
const result = await form.validateField('email')
validateField() still validates using the complete schema.
The important difference is what happens to the errors:
- errors for the requested path are replaced
- errors for other paths remain untouched
This allows one field to be revalidated without resetting the validation state of the rest of the form.
await form.validateField('email')
is therefore different from:
await form.validate()
The first updates one field's error state; the second replaces the complete error collection.
Validation Issues
NotForm stores validation failures as:
StandardSchemaV1.Issue[]
An issue can contain a path and message:
{
path: ['email'],
message: 'Invalid email address',
}
Nested paths use multiple segments:
{
path: ['profile', 'email'],
message: 'Invalid email address',
}
NotForm uses these paths for:
form.getFieldErrors('profile.email')
form.errorsMap['profile.email']
and components such as:
<NotMessage path="profile.email" />
Multiple Issues
A schema can return multiple issues for the same field.
For example:
const schema = z.object({
password: z
.string()
.min(8, 'Must be at least 8 characters')
.regex(
/[A-Z]/,
'Must contain an uppercase letter',
)
.regex(
/[0-9]/,
'Must contain a number',
),
})
The raw issues are available through:
form.errors
or:
form.getFieldErrors('password')
For simple UI rendering, form.errorsMap.password contains the first message for the field.
Custom Error Messages
NotForm does not rewrite schema messages.
The validation library determines the message:
const schema = z.object({
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(
/[A-Z]/,
'Must contain an uppercase letter',
)
.regex(
/[0-9]/,
'Must contain at least one number',
),
})
Those messages become the message properties of the resulting issues.
This means validation rules and validation copy remain inside your schema.
Async Validation
Standard Schema validation can be asynchronous.
For example, checking username availability:
const schema = z.object({
username: z.string().refine(
async (value) => {
const response = await fetch(`/api/check-username?username=${value}`,)
const data = await response.json()
return data.available
},
{
message: 'Username is already taken',
},
),
})
Use the schema normally:
const form = useNotForm({
schema,
onSubmit(values) {
console.log(values)
},
})
NotForm awaits the schema's validation result.
While validation is running:
form.isValidating
is true.
This makes it possible to show asynchronous feedback:
<p v-if="form.isValidating">
Checking username...
</p>
Async Validation and Triggers
Async validation works with the same validateOn configuration as synchronous validation.
For example:
const form = useNotForm({
schema,
validateOn: {
onBlur: true,
onInput: false,
},
validationMode: 'lazy',
onSubmit(values) {
console.log(values)
},
})
Now the expensive asynchronous validator does not run on every input interaction.
See Validation Modes for controlling these triggers and modes.
Schema Transformations
One of the advantages of keeping input and output types separate is that schemas can transform or normalize data.
For example:
const schema = z.object({
name: z.string().transform(value => value.trim(),),
})
The form edits the schema input:
form.values.name
while successful submission receives the schema output:
onSubmit(values) {
// transformed value
console.log(values.name)
}
The validation layer therefore becomes the boundary between editable form state and validated application data.
Schema as a Ref or Getter
schema accepts a MaybeRefOrGetter, so the schema can be resolved dynamically.
For example:
const schema = computed(() => {
return isCompany.value
? companySchema
: personSchema
})
const form = useNotForm({
schema,
onSubmit(values) {
console.log(values)
},
})
NotForm resolves the current schema when validation runs.
This allows validation rules to depend on reactive application state.
Validation Does Not Mutate Your Schema
NotForm delegates validation to the supplied schema.
The form owns:
form.values
form.errors
form.isValid
form.isValidating
The schema owns:
validation rules
transformations
issue messages
input/output validation
Keeping those responsibilities separate means your schema can be reused outside the form.
Server Validation
A server can still reject data after client validation succeeds.
Those backend errors can be represented with the same StandardSchemaV1.Issue structure:
form.setErrors([
{
message: 'This email is already registered.',
path: ['email'],
},
])
This allows client and server errors to share the same form error state.
See Server Errors for the complete server-error workflow.
Choosing a Validator
NotForm does not require a particular validator.
Choose the Standard Schema-compatible library that best fits your project.
For example:
- Zod for a broadly adopted schema API
- Valibot for a lightweight modular approach
- ArkType for type-oriented schemas
Regardless of the validator, the NotForm API remains the same:
const form = useNotForm({
schema,
onSubmit(values) {
console.log(values)
},
})