Saltar al contenido

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.

5 min de lectura
Arquitectura de formulario con tipado seguro que muestra tipos genéricos de TypeScript fluyendo desde la definición del esquema hasta el registro de campos y la salida validada del envío

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.

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>;

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.

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;
  }
}

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.

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);
});

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.

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>
  );
}

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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX