Validation
Validation in Goodie Forms is completely optional. If you don't provide any validation strategy:
validateForm()does nothing.validateField()does nothing.controller.issueswill always remain empty.
In other words, the form controller will never produce validation errors on its own. Goodie Forms does not assume, infer, or auto-generate any validation rules. It stays silent unless you explicitly define how validation should work.
That said, providing a validation strategy is highly encouraged for maintaining data integrity and ensuring that the submitted data matches your expected shape.
Goodie Forms takes a headless approach to validation:
- It does not enforce any specific validation library.
- It does not mutate or transform your data implicitly.
- It does not couple validation to UI behavior.
Instead, validation is treated as a pluggable concern. You define:
- What valid data looks like.
- When validation runs.
- How issues are produced.
You can use either:
- A Standard Schema based approach (recommended for most cases)
- Or a custom validation strategy when you need complete control over the logic via
customValidation().
Goodie Forms simply executes your strategy and stores the resulting issues in controller.issues.
Everything else — rendering errors, blocking submission, triggering field validation — is entirely up to you.
Standard Schema Validation
For most applications, schema-based validation is the recommended approach.
Goodie Forms supports Standard Schema compatible validators, allowing you to plug in your preferred schema library (See full list) while keeping the core fully headless and type-safe. With a schema:
- Your data shape is defined in one place.
- Validation rules live alongside the structure.
- TypeScript can infer the final validated output.
validateForm()andvalidateField()automatically populatecontroller.issueswhen violations occur.
This approach works especially well when:
- You already use a schema library in your backend.
- You want a single source of truth for data structure.
- You need consistent validation across multiple forms.
- You prefer declarative validation over manual logic.
Schema validation keeps your form predictable, maintainable, and aligned with your domain model — without coupling Goodie Forms to any specific validation library.
📚 Example: zod Validation
Zod Official Docs
Zod GitHub Repository
import { FormController } from "@goodie-forms/core";
import z from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type Output = z.infer<typeof schema>;
const form = new FormController({
validationSchema: schema,
initialData: {
email: "",
password: "",
},
});
const onSubmit = form.createSubmitHandler(
async (data) => {
// ^? Expected to match type Output
console.log("Valid data:", data);
},
async (issues) => {
console.log("Validation issues:", issues);
},
);
📚 Example: volibot Validation
Volibot Official Docs
Volibot GitHub Repository
import { FormController } from "@goodie-forms/core";
import * as v from "valibot";
const schema = v.object({
username: v.pipe(v.string(), v.minLength(3)),
age: v.pipe(v.number(), v.minValue(18)),
});
type Output = v.InferOutput<typeof schema>;
const form = new FormController({
validationSchema: schema,
initialData: {
username: "",
age: 0,
},
});
const onSubmit = form.createSubmitHandler(
async (data) => {
// ^? Expected to match type Output
console.log("Valid data:", data);
},
async (issues) => {
console.log("Validation issues:", issues);
},
);
📚 Example: ArkType Validation
ArkType Official Docs
ArkType GitHub Repository
import { FormController } from "@goodie-forms/core";
import { type } from "arktype";
const schema = type({
email: "string.email",
password: "string >= 8",
rememberMe: "boolean?",
});
type Output = typeof schema.infer;
const form = new FormController({
validationSchema: schema,
initialData: {
email: "",
password: "",
rememberMe: false,
},
});
const onSubmit = form.createSubmitHandler(
async (data) => {
// ^? Expected to match type Output
console.log("Valid data:", data);
},
async (issues) => {
console.log("Validation issues:", issues);
},
);
📚 Other Standard Schema Libraries
You can use any validation library that implements the Standard Schema specification.
Standard Schema Libraries
Custom Validation
export type CustomValidationIssue<TOutput extends object> = {
path: FieldPath.StringPaths<TOutput>;
message: string;
};
export type CustomValidationStrategy<TOutput extends object> = (
data: DeepPartial<TOutput>,
) =>
| void
| CustomValidationIssue<TOutput>[]
| Promise<CustomValidationIssue<TOutput>[] | void>;
export function customValidation<TOutput extends object>(
strategy: CustomValidationStrategy<TOutput>,
);
- TOutputrepresents the final validated shape of your form data. It defines the full object structure that the form is expected to produce after successful validation and submission.
@goodie-forms/core exposes customValidation() for the cases where you need to implement your own validation logic that doesn't fit into a standard schema.
This is the lowest-level and most flexible validation mechanism available in Goodie Forms.
Instead of describing rules declaratively (like in a schema), you provide a function that:
- Receives the current form data (
DeepPartial<TOutput>) - Returns nothing if the data is valid
- Or returns an array of structured validation issues (
StandardSchemaV1.Issue[]) withpathasFieldPath.StringPath - May also be asynchronous
📚 Example: Custom Validation
import { FormController, customValidation } from "@goodie-forms/core";
type Output = {
password: string;
confirmPassword: string;
activationDigits: number[];
};
const form = new FormController({
initialData: {
password: "",
confirmPassword: "",
},
validationSchema: customValidation<Output>((data) => {
const issues = [];
if (data.password && data.password.length < 8) {
issues.push({
path: "password",
// ^? IntelliSense will suggest StringPaths of Output here
message: "Password must be at least 8 characters long",
});
}
if (data.password !== data.confirmPassword) {
issues.push({
path: "confirmPassword",
// ^? IntelliSense will suggest StringPaths of Output here
message: "Passwords do not match",
});
}
for (let i = 0; i < data.activationDigits?.length; i++) {
if (data.activationDigits[i] < 0 || data.activationDigits[i] > 9) {
issues.push({
path: `activationDigits[${i}]`,
// ^? IntelliSense will suggest StringPaths of Output here
message: "Activation digits must be between 0 and 9",
});
}
}
return issues.length ? issues : undefined;
}),
});