Von der ersten Interface bis zur erweiterten HTML-Komponente
Wer React-Props ohne klare Typen schreibt, verlässt sich auf Konventionen statt auf den Compiler. TypeScript macht aus stillschweigenden Annahmen über Props explizite Verträge: Welche Werte sind Pflicht, welche optional, welche Form dürfen Children annehmen und welche zusätzlichen HTML-Attribute soll eine Komponente durchreichen. Dieser Artikel zeigt, wie man React-Props von der ersten Interface-Definition bis zu erweiterten, wiederverwendbaren Komponenten sauber typisiert.
Inhaltsverzeichnis
- 1. Warum React-Props überhaupt typisieren
- 2. Das Basis-Interface: Pflichtfelder und Struktur
- 3. Children richtig typisieren: ReactNode vs. ReactElement
- 4. Optionale Props und sinnvolle Default-Werte
- 5. Varianten mit Union Types und Discriminated Props
- 6. Render Props und Function-as-Children typisieren
- 7. Native HTML-Attribute mit ComponentPropsWithoutRef erweitern
- 8. Compile-Zeit-Typen vs. Laufzeit-Validierung
- 9. Props-Typisierung im Vergleich: Ansätze und ihre Grenzen
- 10. Zusammenfassung
- 11. FAQ
1. Warum React-Props überhaupt typisieren
Eine React-Komponente ohne typisierte Props funktioniert im Editor wie eine Blackbox: Man sieht den Funktionsnamen, aber nicht, welche Werte erwartet werden, welche davon Pflicht sind und welche Form die Rückgabe hat. In einer reinen JavaScript-Komponente steht diese Information höchstens in einem Kommentar, der beim nächsten Refactoring veraltet, ohne dass irgendetwas den Entwickler darauf hinweist. TypeScript macht diese Verträge explizit, indem React-Props als benanntes Interface deklariert werden, das der Compiler bei jedem einzelnen Aufruf prüft.
Der praktische Nutzen zeigt sich vor allem bei Refactorings. Wird eine Pflicht-Prop entfernt oder umbenannt, meldet der Compiler jede Aufrufstelle, die noch den alten Namen verwendet, sofort als Fehler. Ohne diese Absicherung bleibt der Fehler bis zur Laufzeit verborgen, oft erst sichtbar, wenn ein Nutzer eine leere Seite oder einen Absturz meldet. Gerade in Teams mit mehreren Entwicklern, die dieselben Komponenten aus unterschiedlichen Modulen heraus verwenden, ist diese Rückmeldung während des Kompilierens der entscheidende Unterschied zwischen einem sicheren und einem fragilen Refactoring.
Ein zweiter Vorteil betrifft die Editor-Erfahrung selbst: Sobald React-Props typisiert sind, zeigt die Autovervollständigung exakt die verfügbaren Namen an, inklusive Tooltips mit Kommentaren aus dem Interface. Neue Teammitglieder müssen die Komponente nicht öffnen, um zu verstehen, welche Props existieren, sie sehen es direkt an der Aufrufstelle. Diese Selbstdokumentation ist einer der unterschätzten Effekte konsequenter Typisierung in React-Projekten.
2. Das Basis-Interface: Pflichtfelder und Struktur
Die gebräuchlichste Form, React-Props zu typisieren, ist ein eigenes Interface pro Komponente, benannt nach dem Muster ComponentNameProps. Jedes Feld im Interface entspricht genau einer erwarteten Prop, der Typ beschreibt die erlaubten Werte. Pflichtfelder werden ohne Fragezeichen deklariert, sodass der Compiler einen Fehler wirft, sobald eine Aufrufstelle sie vergisst. Diese Struktur ist bewusst simpel gehalten, weil sie in den meisten Komponenten ausreicht und keine fortgeschrittenen Typkonstrukte benötigt.
Wichtig ist, das Interface direkt neben der Komponente zu exportieren, statt es nur lokal zu verwenden. Andere Module, die dieselbe Komponente in Wrapper-Komponenten oder Tests verwenden, können das Props-Interface dann direkt importieren, statt es erneut zu definieren. Das verhindert, dass zwei leicht unterschiedliche Beschreibungen derselben Props im Projekt existieren, die bei einer Änderung auseinanderlaufen.
// Basic Props interface with required and typed fields
export interface ProductCardProps {
id: number;
name: string;
price: number;
currency: "EUR" | "USD" | "CHF";
onAddToCart: (id: number) => void;
}
export function ProductCard(props: ProductCardProps) {
const { id, name, price, currency, onAddToCart } = props;
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">{name}</h3>
<p>{price.toFixed(2)} {currency}</p>
<button onClick={() => onAddToCart(id)}>In den Warenkorb</button>
</div>
);
}
// Compile error: onAddToCart is missing
// <ProductCard id={1} name="Tastatur" price={79} currency="EUR" />
3. Children richtig typisieren: ReactNode vs. ReactElement
Eine der häufigsten Fragen bei der Typisierung von React-Props betrifft die children-Prop. React selbst erlaubt für children eine erstaunlich breite Palette an Werten: Strings, Zahlen, einzelne Elemente, Arrays von Elementen, Fragmente, sogar null und undefined. Der Typ ReactNode aus dem react-Paket bildet genau diese Bandbreite ab und ist damit die richtige Wahl für nahezu jede Komponente, die beliebigen Inhalt rendern soll, etwa ein Card- oder Layout-Wrapper.
Anders sieht es aus, wenn eine Komponente ausschließlich mit genau einem React-Element als Kind umgehen kann, zum Beispiel weil sie ein ref oder zusätzliche Props via cloneElement auf dieses eine Kind überträgt. In diesem Fall ist ReactElement die präzisere Wahl, weil sie Strings, Zahlen und Arrays explizit ausschließt und der Compiler falsche Verwendungen bereits beim Kompilieren ablehnt. Wer stattdessen überall ReactNode verwendet, verschenkt genau diese Präzision und verschiebt mögliche Fehler in die Laufzeit.
import type { ReactNode, ReactElement } from "react";
// Accepts any renderable content: text, elements, fragments, arrays
interface CardProps {
title: string;
children: ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<section className="rounded-lg border p-4">
<strong>{title}</strong>
{children}
</section>
);
}
// Requires exactly one React element as child (e.g. for cloneElement)
interface TooltipTriggerProps {
children: ReactElement;
label: string;
}
function TooltipTrigger({ children, label }: TooltipTriggerProps) {
return (
<span title={label}>
{children}
</span>
);
}
// Compile error: a string is not a ReactElement
// <TooltipTrigger label="Info">Nur Text</TooltipTrigger>
4. Optionale Props und sinnvolle Default-Werte
Nicht jede Prop ist bei jedem Aufruf zwingend erforderlich. Mit einem Fragezeichen im Interface, etwa size?: "sm" | "md" | "lg", markiert man React-Props als optional, sodass Aufrufstellen sie weglassen dürfen. Der Compiler erlaubt dann sowohl den Aufruf ohne diese Prop als auch mit einem der angegebenen Werte, lehnt aber jeden anderen String konsequent ab. Diese Kombination aus Optionalität und eingeschränkten Werten ist deutlich präziser als ein einfaches size?: string, das jeden beliebigen Text zulassen würde.
Für den tatsächlichen Default-Wert sorgt man am saubersten über Default-Parameter in der Destrukturierung, nicht über eine separate defaultProps-Zuweisung, die in modernen React-Versionen für Funktionskomponenten als veraltet gilt. Der Vorteil von Default-Parametern: Der Typ der destrukturierten Variable bleibt innerhalb der Komponente nicht-optional, weil TypeScript weiß, dass beim Fehlen der Prop garantiert der Default-Wert greift. Das erspart überflüssige Null-Checks im restlichen Komponentencode.
interface ButtonProps {
label: string;
size?: "sm" | "md" | "lg";
disabled?: boolean;
onClick?: () => void;
}
// Default parameters keep `size` non-optional inside the component body
function Button({ label, size = "md", disabled = false, onClick }: ButtonProps) {
const paddingClass = size === "sm" ? "px-2 py-1" : size === "lg" ? "px-6 py-3" : "px-4 py-2";
return (
<button className={paddingClass} disabled={disabled} onClick={onClick}>
{label}
</button>
);
}
// Both calls are valid, size/disabled fall back to their defaults
// <Button label="Speichern" />
// <Button label="Löschen" size="sm" disabled />
5. Varianten mit Union Types und Discriminated Props
Manche Komponenten haben mehrere Varianten, deren Props sich gegenseitig ausschließen. Ein Alert kann zum Beispiel entweder eine einfache Nachricht anzeigen oder zusätzlich eine Retry-Aktion anbieten, aber niemals beide Formen gleichzeitig sinnvoll kombinieren. Ein einzelnes flaches Interface mit lauter optionalen Feldern lässt in diesem Fall ungültige Kombinationen zu, etwa eine onRetry-Funktion ohne den zugehörigen Variant-Typ. React-Props lassen sich stattdessen als Discriminated Union modellieren, bei der ein gemeinsames Feld, meist variant, festlegt, welche der übrigen Felder gültig sind.
Der Compiler nutzt dieses gemeinsame Feld als Diskriminante: Sobald im Code auf props.variant geprüft wird, engt TypeScript den Typ innerhalb des jeweiligen Zweigs automatisch auf die passende Variante ein und macht die dazugehörigen zusätzlichen Felder ohne weitere Prüfung zugänglich. Diese Modellierung verhindert strukturell, dass eine ungültige Kombination von Props überhaupt erst geschrieben werden kann, statt sie erst zur Laufzeit mit einem if-Check abzufangen.
// Discriminated union: "variant" determines which extra props are valid
type AlertProps =
| { variant: "info"; message: string }
| { variant: "error"; message: string; onRetry: () => void };
function Alert(props: AlertProps) {
if (props.variant === "error") {
// TypeScript narrows props to the "error" branch here
return (
<div role="alert">
<p>{props.message}</p>
<button onClick={props.onRetry}>Erneut versuchen</button>
</div>
);
}
return <div role="status">{props.message}</div>;
}
// Compile error: onRetry is not allowed on the "info" variant
// <Alert variant="info" message="Gespeichert" onRetry={() => {}} />
6. Render Props und Function-as-Children typisieren
Manche Komponenten geben ihren Children keine feste Struktur vor, sondern übergeben Daten an eine Funktion, die der aufrufende Code selbst definiert, zum Beispiel eine Liste, die pro Element ein beliebiges Markup rendern lässt. Für dieses Muster typisiert man children nicht als ReactNode, sondern als Funktion mit exaktem Parameter- und Rückgabetyp, etwa children: (item: T) => ReactNode. Diese Signatur macht sichtbar, welche Daten die Komponente an die Render-Funktion übergibt und was als Ergebnis erwartet wird.
In Kombination mit einem generischen Typparameter lässt sich dieses Pattern für beliebige Datentypen wiederverwenden, ohne für jede Liste eine eigene Komponente schreiben zu müssen. Der Vorteil gegenüber einer losen any-Funktion: Innerhalb der Render-Funktion kennt der Compiler exakt die Form von item, inklusive Autovervollständigung für dessen Felder, und lehnt einen Zugriff auf nicht existierende Properties bereits beim Kompilieren ab.
interface ListProps<T> {
items: T[];
children: (item: T, index: number) => ReactNode;
}
// Generic component: T is inferred from the "items" prop
function List<T>({ items, children }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{children(item, index)}</li>
))}
</ul>
);
}
interface Order { id: number; total: number; }
const orders: Order[] = [{ id: 1, total: 49.9 }, { id: 2, total: 129 }];
// Usage: T is inferred as Order, item.total autocompletes
// <List items={orders}>{(order) => <span>{order.total} EUR</span>}</List>
7. Native HTML-Attribute mit ComponentPropsWithoutRef erweitern
Viele eigene Komponenten sind dünne Wrapper um ein natives HTML-Element, etwa ein eigener Button, der zusätzlich zum eigentlichen Klick-Handler auch disabled, type, aria-label und alle anderen üblichen Button-Attribute unterstützen soll. Diese vollständige Liste händisch als eigenes Interface nachzubilden wäre fehleranfällig und würde bei jedem neuen HTML-Standard veralten. React stellt dafür die Hilfstypen ComponentPropsWithoutRef<"button"> und ComponentProps<"button"> bereit, die exakt die Props des jeweiligen nativen Elements abbilden, inklusive aller Standard- und ARIA-Attribute.
Der eigene Props-Typ erweitert diesen Basistyp per Intersection und ergänzt nur die zusätzlichen, eigenen Felder, etwa isLoading. Damit funktioniert die eigene Komponente an der Aufrufstelle exakt wie ein natives <button>, akzeptiert also auch onFocus, className oder form, ohne dass diese Attribute im eigenen Interface manuell aufgelistet werden müssen. Diese Technik ist der Standardweg, um React-Props für Wrapper-Komponenten robust gegenüber neuen HTML-Attributen zu halten.
import type { ComponentPropsWithoutRef } from "react";
// Extends every native button attribute, adds one custom field
type IconButtonProps = ComponentPropsWithoutRef<"button"> & {
isLoading?: boolean;
};
function IconButton({ isLoading = false, children, disabled, ...rest }: IconButtonProps) {
return (
<button disabled={disabled || isLoading} {...rest}>
{isLoading ? "..." : children}
</button>
);
}
// All native button attributes are available, plus isLoading
// <IconButton type="submit" aria-label="Speichern" isLoading>Speichern</IconButton>
8. Compile-Zeit-Typen vs. Laufzeit-Validierung
Ein wichtiger Punkt, der bei der Typisierung von React-Props häufig übersehen wird: TypeScript-Typen existieren ausschließlich zur Kompilierzeit und verschwinden vollständig beim Kompilieren zu JavaScript. Wenn Props aus einer externen Quelle stammen, die nicht durch TypeScript geprüft wird, etwa aus einer JSON-Antwort eines API-Endpunkts oder aus dynamisch geladenen CMS-Daten, garantiert das Interface allein nicht, dass die tatsächlichen Werte zur Laufzeit passen. Der Compiler vertraut dem deklarierten Typ, auch wenn die reale Datenquelle etwas anderes liefert.
Für diese Grenzfälle ergänzt man die reine Typisierung durch eine Laufzeit-Validierung, etwa mit einer Bibliothek wie Zod, die aus einem Schema sowohl den TypeScript-Typ als auch eine tatsächliche Prüfung zur Laufzeit ableitet. Innerhalb der React-Anwendung selbst, wo Props direkt von einer Elternkomponente übergeben werden, reicht die reine Compile-Zeit-Prüfung dagegen völlig aus, weil beide Seiten desselben Typsystems unterliegen. Die Faustregel: Laufzeit-Validierung an den Rändern der Anwendung, reine TypeScript-Interfaces für alles, was innerhalb des React-Baums fließt.
9. Props-Typisierung im Vergleich: Ansätze und ihre Grenzen
Die folgende Übersicht fasst zusammen, welcher Ansatz zur Typisierung von React-Props für welches Szenario geeignet ist und wo die jeweiligen Grenzen liegen.
| Szenario | Unpassender Ansatz | Empfohlener Ansatz | Vorteil |
|---|---|---|---|
| Beliebiger Inhalt als Kind | children: ReactElement |
children: ReactNode |
Erlaubt Text, Arrays und Fragmente korrekt |
| Sich ausschließende Varianten | flaches Interface, alle Felder optional | Discriminated Union über variant |
Ungültige Kombinationen sind unmöglich |
| Wrapper um natives Element | Attribute manuell im Interface auflisten | ComponentPropsWithoutRef<"button"> |
Bleibt bei neuen HTML-Attributen aktuell |
| Props von externer API | Nur TypeScript-Interface vertrauen | Zod-Schema an der API-Grenze | Prüft tatsächliche Werte zur Laufzeit |
| Individuelles Rendering pro Element | children: ReactNode mit any-Cast |
children: (item: T) => ReactNode |
Volle Typprüfung in der Render-Funktion |
Kein einzelner Ansatz deckt alle Szenarien gleichermaßen ab. Wer ReactNode für jede Situation verwendet, verliert die Präzision, die eine Discriminated Union oder eine spezifische Funktionssignatur böte. Wer umgekehrt jede Komponente mit einer eigenen, vollständig ausgeschriebenen Attributliste versieht, verursacht unnötige Wartungsarbeit, die ComponentPropsWithoutRef bereits automatisch übernimmt. Die richtige Wahl hängt vom konkreten Anwendungsfall der jeweiligen Komponente ab, nicht von einer pauschalen Regel.
Mironsoft
TypeScript-Tooling, typsichere React-Komponenten und Magento/Hyvä-Integrationen
React-Komponenten mit sauberen, typsicheren Props?
Wir analysieren bestehende React-Komponenten, ersetzen lose Any-Props durch klare Interfaces, Discriminated Unions und erweiterte HTML-Typen und sorgen für konsistente Props-Verträge über euer gesamtes Frontend hinweg.
Props-Audit
Bestehende Komponenten auf lose oder fehlende Props-Typen prüfen
Komponenten-Refactoring
Interfaces, Discriminated Unions und ComponentPropsWithoutRef einführen
Schulung
Team-Workshop zu Props-Patterns und typsicheren React-Komponenten
10. Zusammenfassung
Die Typisierung von React-Props beginnt mit einem einfachen, exportierten Interface für Pflichtfelder und wird durch Fragezeichen für optionale Werte, Default-Parameter für sinnvolle Standardwerte und Union-Typen für eingeschränkte Werte ergänzt. Children lassen sich mit ReactNode für beliebigen Inhalt oder mit ReactElement für genau ein Kind präzise beschreiben, während Discriminated Unions sich ausschließende Prop-Kombinationen bereits beim Kompilieren verhindern. Für Wrapper um native HTML-Elemente liefert ComponentPropsWithoutRef automatisch alle relevanten Attribute, ohne manuelle Pflegearbeit bei jedem neuen HTML-Standard.
Wichtig bleibt die Grenze zwischen Compile-Zeit und Laufzeit: TypeScript-Typen verschwinden beim Kompilieren vollständig, deshalb braucht jede Prop, die aus einer externen, nicht typgeprüften Quelle stammt, zusätzlich eine echte Laufzeit-Validierung. Innerhalb des React-Baums selbst, wo Elternkomponente und Kindkomponente demselben Typsystem unterliegen, reicht die reine Interface-Typisierung aus. Wer diese Prinzipien konsequent anwendet, bekommt React-Komponenten, deren Props-Verträge sich beim Refactoring von selbst durchsetzen, statt erst bei Laufzeitfehlern aufzufallen.
React Props und Children typisieren - Das Wichtigste auf einen Blick
Basis-Interface
Ein exportiertes Interface pro Komponente, Pflichtfelder ohne Fragezeichen, klare, eingeschränkte Werte statt string.
Children
ReactNode für beliebigen Inhalt, ReactElement für genau ein Kind, Funktionssignaturen für Render Props.
Varianten & native Elemente
Discriminated Unions für sich ausschließende Props, ComponentPropsWithoutRef für Wrapper um HTML-Elemente.
Grenze zur Laufzeit
TypeScript prüft nur zur Kompilierzeit, externe Datenquellen brauchen zusätzlich Zod oder vergleichbare Laufzeit-Validierung.