Skip to content

Building a Type-Safe Form Library from Scratch

Build a type-safe form library in TypeScript with runtime validation, field-level error tracking, dirty state and a composable API that catches errors early.

5 min read
Type-safe form architecture showing TypeScript generic types flowing from schema definition through field registration to validated submission output

Form libraries either give you full type safety with excessive boilerplate or convenience with runtime surprises. Building one from scratch reveals how to achieve both: TypeScript generics for compile-time safety, a clean API for developer experience, and runtime validation for user input.

The goal is a form library where accessing a non-existent field name is a compile error, submitting returns a fully typed object, and validation errors map to specific fields automatically.

Schema-Driven Type Inference

The form schema defines both the shape of the data and its validation rules. TypeScript infers the form's type from the schema, so you never manually define a form interface.

tstypescript
// ❌ Manual type definitions that drift from validation
interface LoginForm {
  email: string;
  password: string;
}
// Nothing prevents validation from checking fields
// that don't exist in the interface
tstypescript
// ✅ Schema-driven type inference
type FieldValidator<T> = (value: T) => string | null;
 
interface FieldSchema<T> {
  defaultValue: T;
  validators: FieldValidator<T>[];
  label: string;
}
 
type FormSchema = Record<string, FieldSchema<any>>;
 
// Infer the form value type from the schema
type InferFormValues<S extends FormSchema> = {
  [K in keyof S]: S[K] extends FieldSchema<infer T> ? T : never;
};
 
// Define a schema — TypeScript infers types automatically
const loginSchema = {
  email: {
    defaultValue: "",
    validators: [
      (v: string) =>
        v.includes("@") ? null : "Invalid email",
      (v: string) =>
        v.length > 0 ? null : "Email is required",
    ],
    label: "Email",
  },
  password: {
    defaultValue: "",
    validators: [
      (v: string) =>
        v.length >= 8 ? null : "At least 8 characters",
    ],
    label: "Password",
  },
} satisfies FormSchema;
 
// TypeScript infers: { email: string; password: string }
type LoginValues = InferFormValues<typeof loginSchema>;

The satisfies keyword ensures the schema conforms to FormSchema while preserving the specific literal types. This is what makes field name access type-safe downstream.

Form State Management

The form state tracks current values, errors, touched fields, and dirty state. Every mutation is type-checked against the schema.

tstypescript
interface FieldState<T> {
  value: T;
  error: string | null;
  touched: boolean;
  dirty: boolean;
}
 
type FormState<S extends FormSchema> = {
  fields: {
    [K in keyof S]: FieldState<
      S[K] extends FieldSchema<infer T> ? T : never
    >;
  };
  isValid: boolean;
  isSubmitting: boolean;
  submitCount: number;
};
 
class Form<S extends FormSchema> {
  private state: FormState<S>;
  private schema: S;
  private listeners: Set<() => void> = new Set();
 
  constructor(schema: S) {
    this.schema = schema;
    this.state = this.createInitialState(schema);
  }
 
  private createInitialState(schema: S): FormState<S> {
    const fields = {} as FormState<S>["fields"];
 
    for (const [key, field] of Object.entries(schema)) {
      (fields as any)[key] = {
        value: field.defaultValue,
        error: null,
        touched: false,
        dirty: false,
      };
    }
 
    return {
      fields,
      isValid: true,
      isSubmitting: false,
      submitCount: 0,
    };
  }
 
  // Type-safe field access — invalid field names are compile errors
  getField<K extends keyof S>(
    name: K
  ): FieldState<S[K] extends FieldSchema<infer T> ? T : never> {
    return this.state.fields[name];
  }
 
  // Type-safe value setter
  setValue<K extends keyof S>(
    name: K,
    value: S[K] extends FieldSchema<infer T> ? T : never
  ): void {
    const field = this.state.fields[name];
    (field as any).value = value;
    (field as any).dirty =
      value !== this.schema[name as string].defaultValue;
 
    // Validate on change if field was already touched
    if ((field as any).touched) {
      this.validateField(name);
    }
 
    this.notify();
  }
 
  setTouched<K extends keyof S>(name: K): void {
    const field = this.state.fields[name];
    (field as any).touched = true;
    this.validateField(name);
    this.notify();
  }
 
  private validateField<K extends keyof S>(name: K): void {
    const field = this.state.fields[name];
    const schema = this.schema[name as string];
 
    for (const validator of schema.validators) {
      const error = validator((field as any).value);
      if (error) {
        (field as any).error = error;
        this.updateFormValidity();
        return;
      }
    }
 
    (field as any).error = null;
    this.updateFormValidity();
  }
 
  private updateFormValidity(): void {
    this.state.isValid = Object.values(this.state.fields).every(
      (f: any) => f.error === null
    );
  }
 
  subscribe(listener: () => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
 
  private notify(): void {
    for (const listener of this.listeners) {
      listener();
    }
  }
 
  getState(): FormState<S> {
    return this.state;
  }
}

Validated Submission

The submit handler only receives values after all validators pass. The return type is the fully typed form values—not unknown, not any.

tstypescript
type SubmitHandler<S extends FormSchema> = (
  values: InferFormValues<S>
) => Promise<void>;
 
// Extend the Form class with submit logic
class SubmittableForm<S extends FormSchema> extends Form<S> {
  private schema2: S;
 
  constructor(schema: S) {
    super(schema);
    this.schema2 = schema;
  }
 
  async submit(
    handler: SubmitHandler<S>
  ): Promise<{ success: boolean; errors: Record<string, string> }> {
    const state = this.getState();
 
    // Touch all fields to trigger validation display
    for (const key of Object.keys(this.schema2)) {
      this.setTouched(key as keyof S);
    }
 
    // Collect all errors
    const errors: Record<string, string> = {};
    for (const [key, field] of Object.entries(state.fields)) {
      const f = field as FieldState<unknown>;
      if (f.error) {
        errors[key] = f.error;
      }
    }
 
    if (Object.keys(errors).length > 0) {
      return { success: false, errors };
    }
 
    // Extract values — typed as InferFormValues<S>
    const values = {} as InferFormValues<S>;
    for (const [key, field] of Object.entries(state.fields)) {
      (values as any)[key] = (field as FieldState<unknown>).value;
    }
 
    try {
      await handler(values);
      return { success: true, errors: {} };
    } catch (error) {
      return {
        success: false,
        errors: {
          _form:
            error instanceof Error
              ? error.message
              : "Submission failed",
        },
      };
    }
  }
}
 
// Usage — fully type-safe
const loginForm = new SubmittableForm(loginSchema);
 
// ✅ This compiles — 'email' exists in schema
loginForm.setValue("email", "user@example.com");
 
// ❌ This would NOT compile — 'username' doesn't exist
// loginForm.setValue("username", "test");
 
// ❌ This would NOT compile — number is not assignable to string
// loginForm.setValue("email", 42);
 
// Submit handler receives typed values
loginForm.submit(async (values) => {
  // values.email is string
  // values.password is string
  console.log(values.email, values.password);
});

React Integration with Hooks

Connecting the form to React requires a hook that subscribes to state changes and triggers re-renders.

tstypescript
import { useEffect, useRef, useSyncExternalStore } from "react";
 
function useForm<S extends FormSchema>(schema: S) {
  const formRef = useRef(new SubmittableForm(schema));
  const form = formRef.current;
 
  const state = useSyncExternalStore(
    (callback) => form.subscribe(callback),
    () => form.getState()
  );
 
  function register<K extends keyof S>(name: K) {
    const field = state.fields[name] as FieldState<any>;
 
    return {
      value: field.value,
      onChange: (
        e: React.ChangeEvent<HTMLInputElement>
      ) => {
        form.setValue(name, e.target.value as any);
      },
      onBlur: () => form.setTouched(name),
      name: name as string,
      "aria-invalid": field.error ? true : undefined,
      "aria-describedby": field.error
        ? `${String(name)}-error`
        : undefined,
    };
  }
 
  function getError<K extends keyof S>(
    name: K
  ): string | null {
    const field = state.fields[name] as FieldState<any>;
    return field.touched ? field.error : null;
  }
 
  return {
    register,
    getError,
    isValid: state.isValid,
    isSubmitting: state.isSubmitting,
    submit: (handler: SubmitHandler<S>) =>
      form.submit(handler),
  };
}
 
// Component usage
function LoginPage() {
  const { register, getError, isValid, submit } = useForm(
    loginSchema
  );
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await submit(async (values) => {
      // values is { email: string; password: string }
      await api.login(values.email, values.password);
    });
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email")} />
        {getError("email") && (
          <span id="email-error" role="alert">
            {getError("email")}
          </span>
        )}
      </div>
 
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register("password")}
        />
        {getError("password") && (
          <span id="password-error" role="alert">
            {getError("password")}
          </span>
        )}
      </div>
 
      <button type="submit" disabled={!isValid}>
        Sign In
      </button>
    </form>
  );
}

Key Takeaways

Schema-driven forms use TypeScript's satisfies and conditional types to infer form value types from the schema definition, eliminating manual interface declarations that drift from validation logic. Generic type parameters on the Form class ensure that setValue, getField, and submission handlers only accept valid field names and correct value types—invalid access is caught at compile time. Runtime validation executes the same validator functions declared in the schema, with errors mapped to specific fields and displayed only after the field is touched. The useSyncExternalStore hook connects external form state to React's rendering cycle without unnecessary re-renders. Register functions produce spread-ready props including aria-invalid and aria-describedby for accessibility. The resulting API gives you the safety of a fully typed form with the ergonomics of a simple hook—no code generation, no build plugins, just TypeScript generics doing what they're designed to do.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX