Skip to Content
Course content

172: Practice Exercise: Building a Type-Safe Form Builder

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

I've seen this pattern a dozen times in different codebases: someone needs to build a dynamic form builder because the business requirements change every week. They start by creating a flexible system that can handle any field, but in the process, they accidentally throw away the very thing that makes TypeScript useful. They end up with a "flexible" system that is actually just a collection of any types masquerading as a framework.

The Trap of the Generic Record

The naive way to build a form builder is to treat the form state as a simple dictionary. You might define a field configuration and then store the resulting values in a Record<string, any>. It looks clean at first. You can loop through your fields, render an input, and shove the value into an object.

// The "Quick and Dirty" way
type FieldConfig = {
  name: string;
  label: string;
  type: 'text' | 'number' | 'checkbox';
};

const fields: FieldConfig[] = [
  { name: 'username', label: 'Username', type: 'text' },
  { name: 'age', label: 'Age', type: 'number' },
];

const formValues: Record<string, any> = {};

function updateValue(name: string, value: any) {
  formValues[name] = value;
}

Here is where it breaks. The moment you try to use formValues elsewhere in your app—say, sending it to an API—you have zero guarantees. If you accidentally pass a string to the age field, TypeScript won't blink. If you typo username as user_name, the compiler is silent. You've basically downgraded your project to JavaScript for the most critical part of your data entry. I've spent far too many hours debugging "undefined is not a function" errors in production because a form builder like this let a null value slip through into a required field.

Binding the Schema to the State

The better approach is to make the form builder generic. Instead of telling the builder "here are some fields," we tell it "here is the shape of the data I want, and here is how those fields map to that shape." We can use generics to tie the FieldConfig directly to the keys of our data model.

interface UserProfile {
  username: string;
  age: number;
  isPublic: boolean;
}

type FieldConfig<T> = {
  [K in keyof T]: {
    label: string;
    type: 'text' | 'number' | 'checkbox';
  };
};

const profileSchema: FieldConfig<UserProfile> = {
  username: { label: 'Username', type: 'text' },
  age: { label: 'Age', type: 'number' },
  isPublic: { label: 'Public Profile', type: 'checkbox' },
};

By using a mapped type here, we've created a rigid contract. If you add a field to UserProfile but forget to add it to profileSchema, the compiler will scream at you. More importantly, we can now write an updateValue function that knows exactly what type of value belongs to which key.

function updateValue<T>(state: T, key: keyof T, value: T[keyof T]) {
  // To be truly safe, we ensure the value matches the specific key's type
  return { ...state, [key]: value };
}

The Trade-off: Verbosity vs. Certainty

Now, to be fair, this approach requires more boilerplate. You can't just throw a random array of objects at the screen; you have to define your interfaces upfront. When I explain this to junior devs, they often complain that it feels like "extra work."

My response is always the same: would you rather spend ten minutes writing an interface now, or ten hours hunting a type-coercion bug in a production database later? By shifting the burden to the type system, we move the "failure point" from the user's browser to the developer's IDE. That is the entire point of using TypeScript in a professional environment. You aren't just writing code; you're building a safety net.




📋 Practical Task

Exercise: Implementing a Type-Safe Form Manager for User Settings

You need to build a FormManager class that handles the state of a settings form. The goal is to ensure that you cannot update a setting with a value of the wrong type.

Requirements:

  • Define an interface UserSettings with theme (string), notificationsEnabled (boolean), and retryAttempts (number).
  • Create a FormManager<T> class that holds a state of type T.
  • Implement a method setValue<K extends keyof T>(key: K, value: T[K]). This method must update the state while ensuring the value matches the type defined for that specific key in T.
  • Implement a method getValue<K extends keyof T>(key: K): T[K] that returns the correctly typed value.
  • Instantiate the FormManager with UserSettings and demonstrate that attempting to set retryAttempts to a string causes a TypeScript compilation error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.