Typed forms in React and Vue often fail for the same reason: teams try to make one type represent everything. A database record becomes a form model, the form model becomes an API payload, the API payload becomes component props, and validation errors are attached later as loosely typed strings.
That shortcut works until the first reusable field, dynamic form section, localized error message, or partial update endpoint. The better approach is not to add more generics everywhere. It is to type the boundaries that change independently: API responses, form state, component props, emitted events, and error states.
The mistake: one type for too many jobs
A common pattern looks convenient at first:
type User = {
id: string;
email: string;
displayName: string;
role: "admin" | "editor" | "viewer";
createdAt: string;
};
type UserForm = User;This creates hidden coupling. The form now carries fields users should not edit, such as id and createdAt. It may require fields that are not available during creation. It also assumes backend representation and UI representation are identical.
A production form usually needs several related, but distinct, types:
type UserResponse = {
id: string;
email: string;
displayName: string | null;
role: "admin" | "editor" | "viewer";
createdAt: string;
};
type UserFormValues = {
email: string;
displayName: string;
role: "admin" | "editor" | "viewer";
};
type CreateUserPayload = {
email: string;
displayName?: string;
role: "admin" | "editor" | "viewer";
};This is more code, but it is also more honest. The API response can contain nullable values and server fields. The form can normalize null into an empty string. The create payload can omit empty optional values.
The goal of TypeScript in forms is not to remove all runtime checks. It is to make invalid wiring difficult before the user ever opens the page.
Model form values separately from validation errors
Error states are where many typed forms become messy. A weak implementation uses Record<string, string> because it is easy to attach to any form. The problem appears later, when a refactor renames displayName to name and stale error keys survive.
A practical error model should be tied to form fields:
type FieldErrors<TValues> = Partial<{
[K in keyof TValues]: string;
}>;
type FormState<TValues> = {
values: TValues;
errors: FieldErrors<TValues>;
isSubmitting: boolean;
isDirty: boolean;
};
const userForm: FormState<UserFormValues> = {
values: {
email: "",
displayName: "",
role: "viewer",
},
errors: {
email: "Email is required",
},
isSubmitting: false,
isDirty: false,
};This does not solve every validation problem. Nested arrays, conditional fields, and server-side validation often require richer structures. But it gives the team a safe default: errors cannot refer to fields that do not exist.
For nested forms, avoid building a complex type system too early. Start with typed top-level fields, then introduce path-based helpers only when the form shape demands them.
React props and events: type the contract, not the DOM details
In React, reusable inputs often become painful because they mix DOM event types with business-level form updates. Passing React.ChangeEvent<HTMLInputElement> through several layers makes components harder to reuse.
A cleaner pattern is to expose a typed value contract:
type TextFieldProps<TValues, TName extends keyof TValues> = {
name: TName;
label: string;
value: TValues[TName];
error?: string;
onChange: (name: TName, value: TValues[TName]) => void;
};
function TextField<TValues, TName extends keyof TValues>(
props: TextFieldProps<TValues, TName>
) {
return (
<label>
{props.label}
<input
name={String(props.name)}
value={String(props.value ?? "")}
onChange={(event) =>
props.onChange(props.name, event.target.value as TValues[TName])
}
/>
{props.error ? <span role="alert">{props.error}</span> : null}
</label>
);
}This component is not perfect for every field type. The cast is a signal that text inputs produce strings, while TValues[TName] may not always be a string. That is useful feedback. A reusable text field should either restrict the accepted fields to string-like values or use field-specific components for numbers, booleans, dates, and selects.
A stricter text field can make that explicit:
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type StrictTextFieldProps<TValues, TName extends StringKeys<TValues>> = {
name: TName;
value: TValues[TName];
onChange: (name: TName, value: TValues[TName]) => void;
};This is where generics help. They should prevent incorrect component use, not make the component look sophisticated.
Vue props and emits: keep event payloads explicit
Vue applications hit the same problem through a different API surface. Props may be typed, but emitted events are often left as strings with loosely shaped payloads. That weakens reusable UI because the parent component can listen to an event incorrectly without an early signal.
With <script setup lang="ts">, define the emitted contract directly:
type UserFormValues = {
email: string;
displayName: string;
role: "admin" | "editor" | "viewer";
};
type FieldErrors<TValues> = Partial<{
[K in keyof TValues]: string;
}>;
const props = defineProps<{
modelValue: UserFormValues;
errors?: FieldErrors<UserFormValues>;
submitting?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: UserFormValues];
submit: [value: UserFormValues];
}>();
function updateField<K extends keyof UserFormValues>(
key: K,
value: UserFormValues[K]
) {
emit("update:modelValue", {
...props.modelValue,
[key]: value,
});
}The important part is not the syntax. The important part is that the component contract is visible and enforceable. Consumers know which events exist and what payloads they carry.
For generic Vue UI components, prefer generic composables or explicitly typed component factories when the component truly needs to work across multiple models. If the component is only reused by two forms with different labels, generics may be unnecessary. If it is reused across a design system, generic field contracts can reduce duplication.
API responses should be validated before they become form state
TypeScript cannot prove that an HTTP response matches your declared type. fetch().json() returns runtime data. Assigning it to a type does not validate it.
A safe frontend boundary looks like this:
async function getUser(id: string): Promise<UserResponse> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error("Failed to load user");
}
const data: unknown = await response.json();
return parseUserResponse(data);
}
function toUserFormValues(user: UserResponse): UserFormValues {
return {
email: user.email,
displayName: user.displayName ?? "",
role: user.role,
};
}The missing piece is parseUserResponse. It can be a schema validator, a hand-written guard, or an adapter generated from a contract. The specific tool is less important than the boundary itself.
Keep the conversion from API response to form values close to the data loading code. That keeps nullable backend fields, transport formats, and UI defaults from leaking into every input component.
Shortcut vs production-grade typing
The right level of typing depends on the risk and reuse level. A small internal form does not need a full design-system abstraction. A shared form library used across product teams does.
Area | Shortcut implementation | Production-grade implementation | Runtime effect | Maintenance effect |
|---|---|---|---|---|
Form values | Reuse API response type | Separate response, form, and payload types | Fewer accidental server fields in UI state | Easier endpoint and UI changes |
Errors | Record<string, string> | Partial<Record<keyof TValues, string>> or mapped type | No runtime improvement by itself | Refactors catch stale error keys |
Events | Raw DOM events passed upward | Business-level onChange(name, value) or typed emits | Less event plumbing | Components are easier to reuse |
Generic fields | One component accepts every value type | Field components constrained by value kind | Fewer invalid conversions | Safer reusable UI |
API data | Trust declared response type | Parse or guard unknown response data | Earlier failure on malformed data | Clear boundary between transport and UI |
This table is not a rulebook. It is a pressure map. The more shared and long-lived the form is, the more valuable explicit boundaries become.
Practical adoption order
Teams do not need to rewrite every form to benefit from better typing. Start where the defects usually appear.
Separate API response types from form values. This removes the most dangerous coupling.
Type error objects using form keys. This catches common refactor mistakes.
Expose reusable inputs through value contracts. Avoid passing framework-specific DOM events through the form layer.
Type emitted events in Vue and callback payloads in React. Parent components should not guess payload shape.
Validate or adapt API responses at the boundary. Do not let unknown transport data become trusted form state.
This order works because it improves correctness without forcing a full form framework decision. It also gives teams room to adopt libraries later without losing the domain types they already created.
When generics are worth the cost
Generics are useful when they encode a real relationship:
A field name must exist in the form model.
A field value must match the selected field name.
An error object must use valid form keys.
A select option value must match the field value type.
A submit handler must receive the same model the form edits.
Generics are not useful when they hide uncertainty. If a component accepts unknown, casts internally, and emits loosely typed values, the generic parameter is decorative. It may make the code harder to read without improving safety.
A good test is simple: remove the generic and see what breaks. If nothing important breaks, the abstraction may not be carrying its weight.
For engineers who work with TypeScript as a production language rather than just a type layer over JavaScript, the Senior TypeScript Developer certification is the most relevant DevCerts track to review.
Conclusion
Typed forms in React and Vue become manageable when teams stop treating types as annotations and start treating them as boundaries. API responses, form values, props, events, payloads, and errors are related, but they should not collapse into one shared shape.
The practical path is clear: separate transport data from UI state, make errors depend on real field names, expose reusable components through typed value contracts, and validate unknown API data before it reaches the form. Generics should support those boundaries, not replace them.
That approach reduces refactor risk, improves reusable UI, and gives teams a form architecture that can grow without turning every input into a type puzzle.