Skip to Content
Course content

79: Building a Typed Form Validation Library

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of time reviewing PRs for form-heavy applications, and I keep seeing the same pattern. A developer creates a nice interface for their form data, then writes a validation function that takes any or a Record<string, any>, and finally uses a type assertion like as UserProfile to "force" the result into the correct type. They think they've solved the problem because the TypeScript compiler stops complaining.

The Fallacy of "Casting After Validation"

The problem is that casting is a lie you tell the compiler. If you validate your data and then cast it, you've created a disconnected bridge. If you rename userName to handle in your interface but forget to update the validation logic, TypeScript won't warn you. Your validator will happily check for userName, find it missing, and your casted object will suddenly have an undefined value where you promised the compiler a string would be. This is exactly how "cannot read property of undefined" bugs sneak into production.

// The dangerous way
interface UserProfile {
  username: string;
  age: number;
}

function validate(data: any) {
  if (!data.username) throw new Error("Missing username");
  // What if 'age' is missing? The cast below hides the danger.
  return data as UserProfile; 
}

Letting the Schema Drive the Type

To do this right, we need to flip the relationship. Instead of the interface driving the validator, the validation schema should drive the type. We can achieve this using mapped types. We want to define a set of rules, and then have TypeScript automatically infer the shape of the data those rules are protecting.

Let's build a small library where we define a ValidationRule and a Schema. I prefer using a function that returns a string error or null if the value is valid. It's simple and composable.

type ValidationRule<T> = (value: T) => string | null;

// This is the magic: we map over the keys of T and assign a rule for each value type
type Schema<T> = {
  [K in keyof T]: ValidationRule<T[K]>;
};

class FormValidator<T> {
  constructor(private schema: Schema<T>) {}

  validate(data: Partial<T>): { isValid: boolean; errors: Partial<Record<keyof T, string>> } {
    const errors: Partial<Record<keyof T, string>> = {};
    let isValid = true;

    for (const key in this.schema) {
      const rule = this.schema[key as keyof T];
      const value = data[key as keyof T];
      const error = rule(value as T[keyof T]);

      if (error) {
        errors[key as keyof T] = error;
        isValid = false;
      }
    }

    return { isValid, errors };
  }
}

Connecting Logic to Constraints

Now, when we actually use this library, the type safety is bidirectional. If I try to add a validation rule for a field that doesn't exist in my UserProfile interface, TypeScript will stop me. If I change the type of age from a number to a string, the rule for age will immediately flag a type error because it's still expecting a number.

I usually keep my rules in a separate utility file so I can reuse them across different forms. Here is how that looks in practice:

interface UserProfile {
  username: string;
  age: number;
  email: string;
}

// Reusable rules
const required = <T>(value: T) => (value === undefined || value === null || value === "") ? "This field is required" : null;
const minLength = (min: number) => (value: string) => value.length < min ? `Must be at least ${min} characters` : null;
const isPositive = (value: number) => value < 0 ? "Must be a positive number" : null;

const userSchema: Schema<UserProfile> = {
  username: (val) => required(val) || minLength(3)(val) || null,
  age: (val) => required(val) || isPositive(val) || null,
  email: (val) => required(val) || (!val.includes("@") ? "Invalid email" : null),
};

const validator = new FormValidator(userSchema);
const result = validator.validate({ username: "Jo", age: -1 });

// result.errors.username will be "Must be at least 3 characters"
// result.errors.age will be "Must be a positive number"

Notice how we aren't using any anywhere in the implementation logic. We're using generics (T) and mapped types to ensure that the FormValidator knows exactly which keys it's dealing with based on the interface provided. This is the "professional" way to handle forms in TypeScript: the types aren't just documentation; they are the guardrails for your logic.




📋 Practical Task

Implement a Typed Product Inventory Validator

You are building a warehouse management system. You need to create a typed validation system for adding new products to the inventory. Complete the following implementation:

  • Create an interface Product with sku (string), price (number), and quantity (number).
  • Implement a ProductSchema using the Schema<T> pattern from the lesson.
  • Add a rule for sku: it must start with "PROD-".
  • Add a rule for price: it must be greater than 0.
  • Add a rule for quantity: it must be 0 or greater.
  • Instantiate a FormValidator and validate an object with an invalid SKU (e.g., "ITEM-123") and a negative price.
  • Log the errors object to the console to verify the validation logic caught both mistakes.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.