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.

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.
// ❌ 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// ✅ 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.
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.
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.
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.


