Zum Inhalt springen

Eine typsichere Formular-Bibliothek von Grund auf bauen

Baue eine typsichere Formular-Bibliothek in TypeScript: Laufzeitvalidierung, Fehler pro Feld, Dirty-State und eine komponierbare API mit Compile-Checks.

5 Min. Lesezeit
Typsichere Formulararchitektur: TypeScript-Generics fließen von der Schemadefinition über die Feldregistrierung bis zur validierten Ausgabe der Formularübermittlung

Formular-Bibliotheken geben dir entweder vollständige Typsicherheit mit übermäßigem Boilerplate oder Bequemlichkeit mit Überraschungen zur Laufzeit. Eine von Grund auf zu bauen zeigt, wie man beides erreicht: TypeScript-Generics für Sicherheit zur Kompilierzeit, eine saubere API für die Entwicklererfahrung und Laufzeitvalidierung für Benutzereingaben.

Das Ziel ist eine Formular-Bibliothek, bei der der Zugriff auf einen nicht existierenden Feldnamen ein Kompilierfehler ist, das Absenden ein vollständig typisiertes Objekt zurückgibt und Validierungsfehler automatisch bestimmten Feldern zugeordnet werden.

Schemagesteuerte Typinferenz

Das Formularschema definiert sowohl die Form der Daten als auch ihre Validierungsregeln. TypeScript leitet den Typ des Formulars aus dem Schema ab, sodass du nie manuell eine Formularschnittstelle definieren musst.

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

Das Schlüsselwort satisfies stellt sicher, dass das Schema FormSchema entspricht, während die spezifischen Literaltypen erhalten bleiben. Genau das macht den Feldnamenzugriff nachgelagert typsicher.

Verwaltung des Formularzustands

Der Formularzustand verfolgt aktuelle Werte, Fehler, berührte Felder und den Dirty-State. Jede Mutation wird gegen das Schema typgeprüft.

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

Validiertes Absenden

Der Submit-Handler erhält Werte erst, nachdem alle Validatoren erfolgreich durchlaufen wurden. Der Rückgabetyp sind die vollständig typisierten Formularwerte — nicht unknown, nicht 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 mit Hooks

Die Verbindung des Formulars mit React erfordert einen Hook, der Zustandsänderungen abonniert und Re-Renders auslöst.

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

Wichtige Erkenntnisse

Schemagesteuerte Formulare nutzen TypeScripts satisfies und Conditional Types, um die Typen der Formularwerte aus der Schemadefinition abzuleiten, und eliminieren damit manuelle Schnittstellendeklarationen, die von der Validierungslogik abdriften. Generische Typparameter in der Form-Klasse stellen sicher, dass setValue, getField und Submit-Handler nur gültige Feldnamen und korrekte Werttypen akzeptieren — ungültige Zugriffe werden zur Kompilierzeit erkannt. Die Laufzeitvalidierung führt dieselben Validator-Funktionen aus, die im Schema deklariert sind, wobei Fehler bestimmten Feldern zugeordnet und erst angezeigt werden, nachdem das Feld berührt wurde. Der useSyncExternalStore-Hook verbindet den externen Formularzustand mit Reacts Renderzyklus ohne unnötige Re-Renders. Register-Funktionen erzeugen spread-fertige Props einschließlich aria-invalid und aria-describedby für Barrierefreiheit. Die resultierende API gibt dir die Sicherheit eines vollständig typisierten Formulars mit der Ergonomie eines einfachen Hooks — keine Codegenerierung, keine Build-Plugins, nur TypeScript-Generics, die genau das tun, wofür sie gedacht sind.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX