惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

U
Unit 42
Google DeepMind News
Google DeepMind News
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
MongoDB | Blog
MongoDB | Blog
I
InfoQ
N
Netflix TechBlog - Medium
T
Tailwind CSS Blog
量子位
博客园 - 叶小钗
月光博客
月光博客
IT之家
IT之家
G
Google Developers Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
小众软件
小众软件
S
SegmentFault 最新的问题
Engineering at Meta
Engineering at Meta
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
aimingoo的专栏
aimingoo的专栏
云风的 BLOG
云风的 BLOG
Vercel News
Vercel News
爱范儿
爱范儿
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
宝玉的分享
宝玉的分享

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
How to Build Type-Safe Form Handlers Using TypeScript Uti...
137Foundry · 2026-05-20 · via DEV Community

Form handling is one of the areas where TypeScript utility types save the most repetition. A typical form involves at least three distinct type states: the initial (possibly empty) form, the in-progress state as the user fills it out, and the validated payload ready for submission. Without utility types, you write three separate interfaces and keep them in sync manually. With utility types, you write one and derive the rest.

Step 1: Define the Submission Schema

Start with the type that represents the complete, valid submission -- every field required, no optionals.

interface ContactFormData {
  name: string;
  email: string;
  company: string;
  message: string;
  acceptedTerms: boolean;
}

Enter fullscreen mode Exit fullscreen mode

This is your canonical type. Everything else derives from it.

Step 2: Derive the Form State Type

Form state is the same shape as the submission type, but every field is optional. A user may not have filled anything in yet, and you do not want TypeScript complaining about empty strings or undefined fields during the editing phase.

type ContactFormState = Partial<ContactFormData>;

Enter fullscreen mode Exit fullscreen mode

That is it. One line. ContactFormState has the same fields as ContactFormData but all optional.

In a React component:

import { useState } from 'react';

function ContactForm() {
  const [formState, setFormState] = useState<ContactFormState>({});

  function handleChange(field: keyof ContactFormData, value: string | boolean) {
    setFormState(prev => ({ ...prev, [field]: value }));
  }

  // ...
}

Enter fullscreen mode Exit fullscreen mode

Using keyof ContactFormData for the field parameter means TypeScript will catch any typo in field names at the call site. If you rename a field, every call to handleChange with the old name becomes a compile error.

Step 3: Define the Validated Type

After validation, you know all fields are present. Required<T> makes this explicit:

type ValidatedContactForm = Required<ContactFormData>;

Enter fullscreen mode Exit fullscreen mode

Since ContactFormData already has all required fields, ValidatedContactForm is structurally identical here. The value is when your base type has optional fields -- Required<T> strips all ? modifiers and gives you a type you can assert after validation.

function validateForm(state: ContactFormState): asserts state is ValidatedContactForm {
  const required: (keyof ContactFormData)[] = [
    'name', 'email', 'company', 'message', 'acceptedTerms'
  ];

  for (const field of required) {
    if (state[field] === undefined || state[field] === '') {
      throw new Error(`${field} is required`);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

After validateForm(state) runs without throwing, TypeScript knows state is ValidatedContactForm -- all fields are present and non-undefined.

Step 4: Handle Partial Pre-Population

Some forms pre-populate from existing data -- editing a profile, resuming a draft. The source data is usually a different type than the form type.

interface UserProfile {
  id: string;
  displayName: string;
  email: string;
  bio?: string;
  avatarUrl: string;
  createdAt: Date;
}

interface ProfileFormData {
  displayName: string;
  email: string;
  bio: string;
}

// Initialize form from profile data
function initFormFromProfile(profile: UserProfile): Partial<ProfileFormData> {
  return {
    displayName: profile.displayName,
    email: profile.email,
    bio: profile.bio ?? '',
  };
}

Enter fullscreen mode Exit fullscreen mode

The function returns Partial<ProfileFormData> because bio is optional on UserProfile and may be absent. The form starts pre-populated with what is available.

Step 5: Build a Reusable Field Change Handler

With Partial<T> and keyof T, you can build a generic field change handler that works for any form:

type FormUpdater<T> = (field: keyof T, value: T[keyof T]) => void;

function useFormState<T>(initial: Partial<T> = {}) {
  const [state, setState] = useState<Partial<T>>(initial);

  const update: FormUpdater<T> = (field, value) => {
    setState(prev => ({ ...prev, [field]: value }));
  };

  return { state, update };
}

Enter fullscreen mode Exit fullscreen mode

Usage:

const { state, update } = useFormState<ContactFormData>();

update('name', 'Jane Doe');
update('email', 'jane@example.com');
// TypeScript enforces field names and value types

Enter fullscreen mode Exit fullscreen mode

The update function is fully typed. If ContactFormData.email is a string, passing a number to update('email', 42) is a type error. If you pass a field name that does not exist on ContactFormData, it is also a type error.

Step 6: Handle Multi-Section Forms

For longer forms, you might split fields across sections while still validating against one schema. Pick<T, K> lets you create section-specific types:

interface CheckoutFormData {
  // Shipping
  shippingName: string;
  shippingAddress: string;
  shippingCity: string;
  shippingPostalCode: string;
  // Payment
  cardNumber: string;
  expiryDate: string;
  cvv: string;
  // Promo
  promoCode?: string;
}

type ShippingSection = Pick<CheckoutFormData, 'shippingName' | 'shippingAddress' | 'shippingCity' | 'shippingPostalCode'>;
type PaymentSection = Pick<CheckoutFormData, 'cardNumber' | 'expiryDate' | 'cvv'>;

Enter fullscreen mode Exit fullscreen mode

Each section component receives its relevant slice. You validate each section independently and only combine them into the full CheckoutFormData at submission.

Step 7: Discriminated Union for Form Step State

For multi-step forms, a discriminated union makes the current step state explicit:

type FormStep =
  | { step: 'shipping'; data: Partial<ShippingSection> }
  | { step: 'payment'; data: Partial<PaymentSection> }
  | { step: 'review'; data: CheckoutFormData };

Enter fullscreen mode Exit fullscreen mode

The final step requires CheckoutFormData (fully populated), while earlier steps accept partial data. TypeScript enforces that you cannot reach the review step without complete data.

Validation Library Integration

This pattern works cleanly with Zod and React Hook Form. Zod schemas can infer TypeScript types directly, so you define validation rules and get the type for free:

import { z } from 'zod';

const contactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  company: z.string().min(1),
  message: z.string().min(10),
  acceptedTerms: z.literal(true),
});

type ContactFormData = z.infer<typeof contactSchema>;
type ContactFormState = Partial<ContactFormData>;

Enter fullscreen mode Exit fullscreen mode

The z.infer<typeof contactSchema> approach means you write the schema once and get both runtime validation and compile-time types from it. Partial<T> still applies to the inferred type for form state.

Tracking Touched Fields

A common UX requirement is showing validation errors only after a field has been interacted with -- not on initial load. You need to track which fields have been touched separately from which fields have values.

type TouchedFields<T> = Partial<Record<keyof T, boolean>>;

function useFormWithTouched<T>(initial: Partial<T> = {}) {
  const [state, setState] = useState<Partial<T>>(initial);
  const [touched, setTouched] = useState<TouchedFields<T>>({});

  const touch = (field: keyof T) => {
    setTouched(prev => ({ ...prev, [field]: true }));
  };

  const update = (field: keyof T, value: T[keyof T]) => {
    setState(prev => ({ ...prev, [field]: value }));
  };

  const isTouched = (field: keyof T): boolean => !!touched[field];

  return { state, touch, update, isTouched };
}

Enter fullscreen mode Exit fullscreen mode

Record<keyof T, boolean> creates an object with the same keys as your form data type, each holding a boolean. Partial<Record<keyof T, boolean>> makes all those keys optional so you can start with an empty object. keyof T constrains the touch and isTouched functions to valid field names -- passing a nonexistent field name is a type error.

This pattern composes with the validation step: once a field is touched and its value is present, you run validation and show errors. The state shape is fully typed throughout.

What This Pattern Prevents

Without utility types, the common failure mode is having a ContactFormData interface and a separate ContactFormState interface that are supposed to match but diverge as fields are added or renamed. Someone adds a required phone field to ContactFormData, does not update ContactFormState, and the form silently allows submission without it.

With Partial<ContactFormData> as the form state type, there is no separate interface to keep in sync. The form state is always derived from the submission type. Adding phone to ContactFormData automatically adds it to the form state.

Further Reading

The TypeScript handbook covers Partial, Required, Pick, and keyof with their full type definitions. React documentation covers useState typing patterns. MDN Web Docs is a useful reference for the underlying JavaScript form event handling. For a reference covering all built-in utility types with production patterns for API layers, form handling, and configuration objects, see this guide to TypeScript utility types at TypeScript Utility Types: Practical Code Snippets and Patterns.