Headless Core

Validation

Validate input data before submitting the form.

Validation in Goodie Forms is completely optional. If you don't provide any validation strategy:

  • validateForm() does nothing.
  • validateField() does nothing.
  • controller.issues will 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() and validateField() automatically populate controller.issues when 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 modelwithout coupling Goodie Forms to any specific validation library.

📚 Example: zod Validation

Zod Official Docs

Check out the Zod documentation for more details on how to define schemas and validation rules.

Zod GitHub Repository

Check out the Zod GitHub repository for source code, issues, and community discussions.
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

Check out the Volibot documentation for more details on how to define schemas and validation rules.

Volibot GitHub Repository

Check out the Volibot GitHub repository for source code, issues, and community discussions.
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

Check out the ArkType documentation for more details on how to define schemas and validation rules.

ArkType GitHub Repository

Check out the ArkType GitHub repository for source code, issues, and community discussions.
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

Check out Standard Schema-compatible libraries for more options on schema validation that can be used seamlessly with Goodie Forms.

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[]) with path as FieldPath.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;
  }),
});
Copyright © 2026