Errors and fixes
TypeScript: interface or type for component props
The two forms are not interchangeable, but the difference matters in a few places: type for props, interface for an API other teams must extend.
Key points
- `type` accepts unions and tuples, `interface` does not: for component props `type` wins.
- `interface` can be extended by a second declaration, which only helps public APIs or module augmentation.
- The compiler performance gap is negligible next to the readability you get.
Short answer: use type for component props. Use interface for an API that other teams must be able to extend. The rest is preference, and the preference should not change from file to file.
The mechanism: type registry versus type expression
An interface introduces a name into the type registry and leaves it open: anyone can add a second declaration with that name and the compiler merges both. A type binds a name to a type expression: the expression can be anything, the declaration is closed, and a second one with the same name is an error.
In the simple case the compiler resolves both into the same structure and assignability is identical. The difference appears when the shape must express a choice, or when it must stay open. Those are the two axes that matter.
The differences that matter
| Property | type | interface |
|---|---|---|
| Union, tuple, mapped types | yes | no |
| Declaration merging | no (error) | yes |
| Extension | & intersection | extends keyword |
| Implicit index | yes, for type literals | no |
| Error messages | usually clearer on unions | closer to the name |
The implicit index deserves a line: a type literal is assignable to an index only when it declares no incompatible property, while an interface never receives an implicit index. That is why the two constructs behave differently when you type an API response.
The shape to use for props
type Tone = "ok" | "warn" | "danger";
type AlertProps = {
tone: Tone;
title: string;
children?: React.ReactNode;
};
export function Alert({ tone, title, children }: AlertProps) {
return (
{title}
{children}
);
}
A type derived from a union blocks any arbitrary string: the error lands in the editor, not at runtime. With an interface you would declare tone: string and lose that check, because a union cannot sit in an extends clause.
Where interface wins
// packages/ui/src/theme.ts
export interface Theme {
colors: Record;
}
// un'app può aggiungere campi senza modificare il pacchetto
declare module "@acme/ui" {
interface Theme {
radii?: Record;
}
}
Declaration merging earns its place at public extension points. It is also the reason not to use it for internal props: two declarations with the same name stack up, and a field removed elsewhere keeps living.
Diagnosis: hunt the cast, not the keyword
When TypeScript accepts a wrong value, the shape of the declaration is not involved. The type was widened somewhere: an as, an implicit any, an unannotated parameter. Look for the casts before you rewrite the type.
grep -rn " as [A-Za-z]\+Props\b" src | head
npx tsc --noEmit --strict
The second command reports whether strict is on. With strictNullChecks switched off, an optional prop and a required one are indistinguishable, and neither form protects you. The proof is an experiment: remove the cast, rebuild, watch where the error lands. If it moves to the caller, the cast was the fault.
When the usual fix makes it worse
The first shortcut is to swap the keyword to make a message disappear. On a union that does not compile, so you go back and split the union into three types: the code passes and the choice between those three states is no longer checked. The message was the only place where the rule was written down.
The second shortcut is to add an index signature, or intersect with Record<string, unknown>, to close an unknown-property error. From then on every key is valid: typos return at runtime and autocomplete stops being a list.
One limit to budget for: merging is global
Declaration merging applies to the program, not to the file. Two packages that augment the same Theme stack up everywhere, tests and other teams' builds included. If one module makes a field required that another never knows about, the break appears far from the change that caused it. With a type the same conflict is an immediate error, and that is the trade you accept when you choose the open extension.
How to prevent it
- House rule: props with
type, shared domain objects withinterfacewhen they must be extended. - Ban
React.FC: type the props and returnReactNode. - Turn on
strictandnoUncheckedIndexedAccess: without them types are decoration. - No
asinside component files: annotate the data entry point instead.
Where to go next
In the catalogue: components, dashboard with shell and blocks. The guides: TypeScript or JavaScript for the UI and UI kit, component library or design system. The comparison: measured React libraries.
Frequently asked questions
Which of the two compiles faster?
The gap only shows on enormous projects, so it is no basis for a choice. Expressiveness comes first, readability for the next reader second.
Why does interface accept a second declaration with the same name?
That is declaration merging, meant for adding fields to existing types. With type the second declaration is an error, which is protection rather than a limitation.
Can I use interface with a union?
No, a union needs a type alias: type Tone = "ok" | "warn" | "danger". The extends clause of an interface takes a name or a list of names only.
What changes for event types?
Nothing: they come from React and you never declare them. The choice between the two constructs covers only the shapes you write in the project.
Do I need React.FC?
No, and plenty of codebases are removing it. Typing props and returning ReactNode is more direct and adds no implicit behaviour such as children.