Una librería de formularios con tipado seguro desde cero
Construye una librería de formularios tipada en TypeScript: validación en runtime, errores por campo, estado dirty y una API componible que detecta fallos.

Las librerías de formularios te dan tipado seguro completo con demasiado boilerplate, o comodidad con sorpresas en tiempo de ejecución. Construir una desde cero revela cómo lograr ambas cosas: genéricos de TypeScript para seguridad en tiempo de compilación, una API limpia para la experiencia de desarrollo y validación en tiempo de ejecución para la entrada del usuario.
El objetivo es una librería de formularios donde acceder a un nombre de campo inexistente sea un error de compilación, el envío devuelva un objeto completamente tipado y los errores de validación se asignen a campos específicos automáticamente.
Inferencia de tipos basada en el esquema
El esquema del formulario define tanto la forma de los datos como sus reglas de validación. TypeScript infiere el tipo del formulario a partir del esquema, así que nunca defines manualmente una interfaz de formulario.
// ❌ 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>;La palabra clave satisfies garantiza que el esquema cumpla con FormSchema conservando los tipos literales específicos. Esto es lo que hace que el acceso a los nombres de campo sea seguro a nivel de tipos más adelante.
Gestión del estado del formulario
El estado del formulario rastrea los valores actuales, los errores, los campos tocados y el estado dirty. Cada mutación se verifica a nivel de tipos contra el esquema.
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;
}
}Envío validado
El manejador de envío solo recibe valores después de que todos los validadores pasen. El tipo de retorno son los valores del formulario completamente tipados, no unknown ni 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);
});Integración con React mediante hooks
Conectar el formulario con React requiere un hook que se suscriba a los cambios de estado y dispare re-renderizados.
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>
);
}Conclusiones clave
Los formularios basados en esquemas usan satisfies y los tipos condicionales de TypeScript para inferir los tipos de los valores del formulario a partir de la definición del esquema, eliminando las declaraciones manuales de interfaces que se desincronizan de la lógica de validación. Los parámetros de tipo genéricos en la clase Form garantizan que setValue, getField y los manejadores de envío solo acepten nombres de campo válidos y tipos de valor correctos; el acceso inválido se detecta en tiempo de compilación. La validación en tiempo de ejecución ejecuta las mismas funciones validadoras declaradas en el esquema, con errores asignados a campos específicos y mostrados solo después de que el campo ha sido tocado. El hook useSyncExternalStore conecta el estado externo del formulario con el ciclo de renderizado de React sin re-renderizados innecesarios. Las funciones register producen props listas para hacer spread, incluyendo aria-invalid y aria-describedby para accesibilidad. La API resultante te da la seguridad de un formulario completamente tipado con la ergonomía de un hook simple: sin generación de código, sin plugins de build, solo genéricos de TypeScript haciendo lo que están diseñados para hacer.


