cat posts/type-hints-mypy-pyright.md
Type Hints in der Praxis: mypy, Pyright und Co.
Typen annotieren, ohne Pythons Dynamik zu verlieren: moderne Type-Hint-Syntax, typische Stolperfallen und der praktische Einstieg mit mypy, Pyright, ty und Pyrefly.
In Teil 11 der Python-Serie kamen Type Hints als Ausblick vor.
Python bleibt eine dynamische Sprache. Diese beiden Zuweisungen sind weiterhin gültiges Python:
wert = 10
wert = "zehn"
Type Hints ändern daran zunächst nichts.
Sie beschreiben, welche Typen an einer bestimmten Stelle erwartet werden:
def erleide_schaden(
hp: int,
menge: int,
) -> int:
return max(
hp - menge,
0,
)
Python selbst macht daraus normalerweise keine Runtime-Prüfung. Ein statischer Type Checker kann aber schon vor dem Start erkennen, dass dieser Aufruf nicht zur Signatur passt:
erleide_schaden(
"viel",
30,
)
Genau darum geht es in diesem Artikel: Type Hints so einzusetzen, dass sie Code verständlicher machen und echte Fehler finden, ohne jede Variable mit möglichst komplizierten Typen zu dekorieren.
Type Hints sind kein Laufzeitschutz
Nehmen wir:
def erleide_schaden(
hp: int,
menge: int,
) -> int:
return max(
hp - menge,
0,
)
Ein korrekter Aufruf:
neue_hp = erleide_schaden(
100,
30,
)
print(neue_hp)
Ausgabe:
70
Dieser Aufruf widerspricht dagegen der Annotation:
erleide_schaden(
"viel",
30,
)
Python prüft beim Funktionsaufruf aber nicht automatisch:
Ist hp wirklich ein int?
Ist menge wirklich ein int?
Kommt wirklich wieder ein int zurück?
Der Code wird ausgeführt und scheitert erst später an:
hp - menge
weil str und int hier nicht zusammenpassen.
Type Hints sind deshalb Informationen für:
- Menschen, die den Code lesen,
- Editoren und Autocompletion,
- Refactoring-Werkzeuge,
- Dokumentationsgeneratoren,
- statische Type Checker.
Wenn Daten aus einer Datei, einem HTTP-Request oder von einem Benutzer kommen, brauchst Du weiterhin Runtime-Validierung.
Funktionen annotieren
Parameter bekommen ihren Typ hinter einem Doppelpunkt:
def begruesse(
name: str,
):
print(f"Hallo, {name}!")
Den Rückgabewert schreibst Du hinter ->:
def verdopple(
zahl: int,
) -> int:
return zahl * 2
Eine Funktion, die keinen sinnvollen Wert zurückgibt:
def zeige_status(
text: str,
) -> None:
print(text)
None bedeutet hier:
Der normale Rückgabewert dieser Funktion ist
None.
Eine Funktion mit tatsächlichem Ergebnis:
def inventar_text(
inventar: list[str],
) -> str:
return ", ".join(inventar)
Dieser Unterschied wird besonders bei Tests und beim Weiterverwenden von Funktionen interessant.
Variablen annotieren
Auch Variablen können Annotationen bekommen:
name: str = "Karl"
hp: int = 100
lebendig: bool = True
Du musst das nicht überall tun.
Ein Type Checker kann hier ohne Hilfe erkennen:
name = "Karl"
hp = 100
dass es sich wahrscheinlich um str und int handelt.
Interessanter sind Fälle wie:
inventar: list[str] = []
Eine leere Liste verrät allein noch nicht, welche Elemente später hineingehören sollen.
Dasselbe gilt für einen anfänglich fehlenden Wert:
aktueller_raum: Raum | None = None
Jetzt ist klar:
Raum oder None
Collections annotieren
Für die eingebauten Collections sieht moderne Python-Syntax so aus:
namen: list[str] = [
"Karl",
"Lara",
]
leben: dict[str, int] = {
"Karl": 100,
"Lara": 80,
}
besuchte_raeume: set[str] = {
"halle",
"bibliothek",
}
position: tuple[int, int] = (
3,
7,
)
Die Formen:
list[str]
dict[str, int]
set[str]
tuple[int, int]
gibt es seit Python 3.9.
In älteren Tutorials findest Du noch:
from typing import Dict, List
namen: List[str]
leben: Dict[str, int]
Für aktuelle Python-Versionen sind die eingebauten Generics normalerweise die klarere Schreibweise.
Nicht immer list und dict verlangen
Ein Type Hint sollte möglichst das beschreiben, was eine Funktion wirklich benötigt.
Diese Funktion verändert die übergebene Collection nicht:
def zeige_namen(
namen: list[str],
) -> None:
for name in namen:
print(name)
Sie funktioniert genauso mit einem Tuple:
zeige_namen(
(
"Karl",
"Lara",
)
)
Also kann die Signatur allgemeiner sein:
from collections.abc import Iterable
def zeige_namen(
namen: Iterable[str],
) -> None:
for name in namen:
print(name)
Iterable[str] bedeutet:
Ein Objekt, über das Strings iteriert werden können.
Wenn zusätzlich Länge und Indexzugriff benötigt werden:
from collections.abc import Sequence
def erster_name(
namen: Sequence[str],
) -> str:
return namen[0]
Dafür passen beispielsweise:
list[str]
tuple[str, ...]
Ein set[str] dagegen nicht.
Für Mappings:
from collections.abc import Mapping
def zeige_hp(
leben: Mapping[str, int],
) -> None:
for name, hp in leben.items():
print(name, hp)
Die Funktion muss kein konkretes dict verlangen, solange sie es nur liest.
Als Orientierung:
list[T] wenn Du wirklich eine mutable Liste brauchst
Sequence[T] wenn Reihenfolge, Länge und Indexzugriff reichen
Iterable[T] wenn Du nur iterierst
Mapping[K,V] wenn Du nur lesend auf Mapping-Daten zugreifst
Zu allgemeine Typen haben ebenfalls Nachteile
Iterable[str] ist nicht automatisch immer besser als list[str].
Wenn die Funktion schreibt:
def entferne_ersten(
namen: list[str],
) -> str:
return namen.pop(0)
braucht sie tatsächlich eine mutable Liste.
Auch ein String ist übrigens selbst ein Iterable von Strings:
for zeichen in "Karl":
print(zeichen)
Eine Signatur:
Iterable[str]
akzeptiert deshalb aus Sicht des Type Systems auch:
"Karl"
Ob das fachlich gewünscht ist, musst Du selbst entscheiden.
Der allgemeinste Typ ist nicht automatisch der beste Typ. Er sollte zum tatsächlichen Vertrag der Funktion passen.
Tupel genauer annotieren
Ein Tuple mit genau zwei Integern:
position: tuple[int, int] = (
3,
7,
)
Ein Tuple mit beliebig vielen Strings:
namen: tuple[str, ...] = (
"Karl",
"Lara",
"Mira",
)
Die drei Punkte bedeuten:
beliebig viele Elemente vom Typ str
Dagegen beschreibt:
tuple[str]
ein Tuple mit genau einem Element:
ein_name: tuple[str] = (
"Karl",
)
Das Komma ist nötig, weil:
("Karl")
nur ein geklammerter String wäre.
None und Union Types
Eine Suche kann etwas finden oder nicht:
def finde_gegenstand(
name: str,
) -> Gegenstand | None:
...
Der Rückgabewert ist also:
Gegenstand oder None
Verwendung:
gegenstand = finde_gegenstand(
"Fackel"
)
if gegenstand is None:
print("Nicht gefunden.")
else:
print(gegenstand.name)
Der Type Checker versteht diese Prüfung.
Vor:
if gegenstand is None:
lautet der Typ:
Gegenstand | None
Im else-Block weiß der Checker:
Gegenstand
Dieses Verengen eines Typs nennt man Narrowing.
Optional bedeutet nicht „optionales Argument“
Älterer Code schreibt häufig:
from typing import Optional
def finde_gegenstand(
name: str,
) -> Optional[Gegenstand]:
...
Das bedeutet dasselbe wie:
Gegenstand | None
Für modernes Python ist die |-Schreibweise meist lesbarer.
Wichtig ist der Begriff:
str | None
bedeutet nicht:
Dieser Funktionsparameter muss nicht angegeben werden.
Es bedeutet nur:
Der Wert darf
stroderNonesein.
Zum Beispiel:
def begruesse(
name: str | None,
) -> None:
...
name ist hier weiterhin ein Pflichtparameter.
Erst ein Default macht ihn beim Aufruf optional:
def begruesse(
name: str | None = None,
) -> None:
...
Mehrere Typen mit |
Union Types können mehr als None enthalten:
def lade_id(
wert: int | str,
) -> int:
return int(wert)
Das kann sinnvoll sein, wenn beide Eingabeformen wirklich Teil des API sind.
Zu viele Union Types machen eine Schnittstelle allerdings schnell schwer verständlich:
int | str | bytes | float | None
Oft ist es besser, flexible Daten am Rand des Programms zu normalisieren und intern mit einem eindeutigen Typ weiterzuarbeiten.
Beispielsweise:
def parse_id(
wert: str,
) -> int:
return int(wert)
Danach bekommen innere Funktionen nur noch int.
Type Aliases
Lange Typen lassen sich benennen:
dict[str, list[tuple[str, int]]]
Seit Python 3.12 gibt es dafür das type-Statement:
type RaumId = str
type GegenstandsId = str
Dann:
def bewege(
ort: RaumId,
ziel: RaumId,
) -> RaumId:
return ziel
Ein Alias macht die Bedeutung klarer.
Er erzeugt aber keinen neuen String-Untertyp. Für den Type Checker bleiben Werte vom Alias grundsätzlich mit dem zugrunde liegenden Typ kompatibel.
Diese Funktion:
def betrete(
raum: RaumId,
) -> None:
...
kann deshalb weiterhin mit einem normalen str aufgerufen werden.
Das type-Statement erzeugt zur Laufzeit ein TypeAliasType-Objekt, aber keine
neue Klasse, deren Instanzen Du statt Strings verwenden würdest.
Generische Type Aliases
Das type-Statement kann auch Parameter besitzen:
type Ergebnis[T] = T | None
Dann beispielsweise:
def finde_raum(
name: str,
) -> Ergebnis[Raum]:
...
Oder:
def finde_gegenstand(
name: str,
) -> Ergebnis[Gegenstand]:
...
Die Syntax mit [T] benötigt Python 3.12 oder neuer.
NewType: wenn zwei Strings nicht dasselbe bedeuten sollen
Ein Type Alias unterscheidet:
RaumId
GegenstandsId
nicht wirklich voneinander.
Wenn genau diese Unterscheidung wichtig ist, gibt es NewType:
from typing import NewType
RaumId = NewType(
"RaumId",
str,
)
GegenstandsId = NewType(
"GegenstandsId",
str,
)
Nun:
halle = RaumId("halle")
fackel = GegenstandsId("fackel")
Eine Funktion:
def betrete(
raum: RaumId,
) -> None:
...
kann von einem Type Checker beanstandet werden, wenn Du versehentlich schreibst:
betrete(fackel)
obwohl beide Werte zur Laufzeit auf Strings basieren.
NewType ist sehr leichtgewichtig. Der erzeugte Callable gibt zur Laufzeit
praktisch den ursprünglichen Wert zurück.
Es ist kein vollwertiger neuer Runtime-Datentyp mit eigenen Methoden.
Wenn Du tatsächliches Verhalten oder Runtime-Unterscheidbarkeit brauchst, ist eine eigene Klasse beziehungsweise Dataclass oft passender.
Literal: nur bestimmte Werte erlauben
Für kleine feste Wertemengen gibt es Literal:
from typing import Literal
type Schwierigkeit = Literal[
"normal",
"schwer",
"alptraum",
]
Dann:
def setze_schwierigkeit(
modus: Schwierigkeit,
) -> None:
print(modus)
Das ist gültig:
setze_schwierigkeit(
"schwer"
)
Ein Type Checker kann dagegen melden:
setze_schwierigkeit(
"brutal"
)
Zur Laufzeit verhindert Literal diesen Aufruf allerdings nicht.
Wenn der String beispielsweise aus einer JSON-Datei stammt, musst Du ihn weiterhin validieren.
Für eine kleine feste Menge von Konfigurationswerten ist Literal angenehm.
Wenn die Werte zusätzliche Bedeutung oder Verhalten besitzen, kann ein Enum
beziehungsweise StrEnum besser passen.
Eigene Klassen als Typen
Eigene Klassen kannst Du direkt annotieren:
class Spieler:
def __init__(
self,
name: str,
max_hp: int = 100,
) -> None:
self.name = name
self.hp = max_hp
self.max_hp = max_hp
def heile_spieler(
spieler: Spieler,
menge: int,
) -> None:
spieler.hp = min(
spieler.hp + menge,
spieler.max_hp,
)
Auch Beziehungen zwischen Klassen werden damit sichtbar:
class Gegenstand:
def __init__(
self,
name: str,
) -> None:
self.name = name
class Raum:
def __init__(
self,
name: str,
gegenstaende: list[Gegenstand],
) -> None:
self.name = name
self.gegenstaende = gegenstaende
Ein Leser muss nicht erst den gesamten Constructor verfolgen, um zu erkennen,
was gegenstaende enthält.
Attribute annotieren
Bei:
self.inventar = []
kann der Checker nur begrenzt wissen, was später in diese Liste soll.
Deshalb:
class Spieler:
def __init__(
self,
name: str,
max_hp: int = 100,
) -> None:
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.inventar: list[Gegenstand] = []
Alternativ stehen die Attribute auf Klassenebene:
class Spieler:
name: str
hp: int
max_hp: int
inventar: list[Gegenstand]
def __init__(
self,
name: str,
max_hp: int = 100,
) -> None:
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.inventar = []
Bei Dataclasses ist dieses Muster ohnehin zentral:
from dataclasses import dataclass, field
@dataclass
class Spieler:
name: str
max_hp: int = 100
hp: int = field(
init=False
)
inventar: list[Gegenstand] = field(
default_factory=list
)
def __post_init__(self) -> None:
self.hp = self.max_hp
Mehr dazu findest Du in Dataclasses: Klassen ohne Boilerplate.
Self für Methoden, die das eigene Objekt zurückgeben
Eine verkettbare Methode könnte so aussehen:
class Spieler:
def setze_gold(
self,
gold: int,
):
self.gold = gold
return self
Mit Self:
from typing import Self
class Spieler:
def __init__(
self,
name: str,
) -> None:
self.name = name
self.gold = 0
def setze_gold(
self,
gold: int,
) -> Self:
self.gold = gold
return self
Nun:
spieler.setze_gold(
50
).setze_gold(
100
)
Self ist präziser als:
-> Spieler
weil bei einer Unterklasse der Rückgabewert weiterhin als diese konkrete Unterklasse verstanden werden kann.
Self gibt es seit Python 3.11.
Generische Funktionen
Manchmal hängt der Rückgabetyp direkt vom Eingabetyp ab.
Diese Funktion:
def erstes_element(
werte,
):
return werte[0]
soll beispielsweise bei:
list[str]
einen str zurückgeben und bei:
list[int]
einen int.
Seit Python 3.12 kannst Du einen eigenen Type Parameter direkt in der Signatur deklarieren:
from collections.abc import Sequence
def erstes_element[T](
werte: Sequence[T],
) -> T:
return werte[0]
Jetzt versteht der Checker:
name = erstes_element(
[
"Karl",
"Lara",
]
)
hp = erstes_element(
[
100,
80,
]
)
name ist:
str
und hp:
int
Das ist deutlich präziser als:
def erstes_element(
werte: Sequence[object],
) -> object:
...
Generische Klassen
Dasselbe funktioniert mit Klassen:
class Beutel[T]:
def __init__(self) -> None:
self._werte: list[T] = []
def lege_hinein(
self,
wert: T,
) -> None:
self._werte.append(wert)
def erstes(
self,
) -> T | None:
if not self._werte:
return None
return self._werte[0]
Dann:
inventar = Beutel[Gegenstand]()
inventar.lege_hinein(
Gegenstand("Fackel")
)
Der Checker weiß, dass:
inventar.erstes()
den Typ:
Gegenstand | None
hat.
Vor Python 3.12 wurde dasselbe meist mit TypeVar und Generic geschrieben:
from typing import Generic, TypeVar
T = TypeVar("T")
class Beutel(Generic[T]):
...
Diese ältere Form bleibt wichtig, wenn Dein Projekt Python 3.11 oder älter unterstützen muss.
Callable: Funktionen als Werte
Auch Funktionen können Type Hints bekommen, wenn sie als Werte übergeben werden.
from collections.abc import Callable
def sortiere_namen(
namen: list[str],
key: Callable[[str], int],
) -> list[str]:
return sorted(
namen,
key=key,
)
Callable[[str], int] bedeutet:
Ein aufrufbares Objekt, das einen
strakzeptiert und einenintzurückliefert.
Damit passt beispielsweise:
namen = [
"Karl",
"Alexandra",
"Mia",
]
sortiert = sortiere_namen(
namen,
key=len,
)
Bei komplizierten Callables werden diese Signaturen allerdings schnell unhandlich.
Dann ist ein Protocol häufig besser lesbar.
Protocol: Verhalten statt konkrete Klasse
Manchmal interessiert nicht die konkrete Klasse eines Objekts.
Es muss nur eine bestimmte Operation unterstützen.
from typing import Protocol
class Beschreibbar(Protocol):
def beschreibe(
self,
) -> str:
...
Eine Funktion:
def zeige_beschreibung(
objekt: Beschreibbar,
) -> None:
print(
objekt.beschreibe()
)
Diese Klasse erfüllt das Protocol:
class Raum:
def beschreibe(
self,
) -> str:
return "Eine dunkle Halle."
Diese ebenfalls:
class Gegenstand:
def beschreibe(
self,
) -> str:
return "Eine rußige Fackel."
Beide müssen nicht ausdrücklich von Beschreibbar erben.
Entscheidend ist ihre Struktur:
besitzt eine kompatible beschreibe()-Methode
Das ist statisches strukturelles Typing und passt gut zu Pythons Duck-Typing-Tradition.
Protocol ist normalerweise nur für den Type Checker
Ein normales Protocol kannst Du nicht automatisch für:
isinstance(
objekt,
Beschreibbar,
)
verwenden.
Wenn genau das notwendig ist:
from typing import (
Protocol,
runtime_checkable,
)
@runtime_checkable
class Beschreibbar(Protocol):
def beschreibe(
self,
) -> str:
...
Dann ist eine Runtime-Prüfung grundsätzlich möglich.
Dabei wird aber nur geprüft, ob die geforderten Attribute vorhanden sind. Eine
vollständige statische Signaturprüfung führt isinstance() natürlich nicht
durch.
TypedDict: Dictionaries mit bekannter Struktur
Bei Daten aus JSON landet man schnell bei:
dict[str, object]
Das sagt aber wenig darüber aus, welche Schlüssel tatsächlich erwartet werden.
Für einen Spielstand ist TypedDict oft hilfreicher:
from typing import TypedDict
class SpielerDaten(TypedDict):
name: str
hp: int
max_hp: int
gold: int
Dann:
def erstelle_spielerdaten(
spieler: Spieler,
) -> SpielerDaten:
return {
"name": spieler.name,
"hp": spieler.hp,
"max_hp": spieler.max_hp,
"gold": spieler.gold,
}
Der Checker kennt nun die erlaubten Keys und ihre Typen.
Das hier kann beispielsweise auffallen:
daten: SpielerDaten = {
"name": "Karl",
"hp": "hundert",
"max_hp": 100,
"gold": 50,
}
weil hp ein int sein soll.
Optionale Keys mit NotRequired
Nicht jeder Key muss zwingend vorhanden sein.
from typing import (
NotRequired,
TypedDict,
)
class SpielerDaten(TypedDict):
name: str
hp: int
gold: int
titel: NotRequired[str]
Nun sind:
name
hp
gold
Pflichtfelder.
titel darf fehlen.
Wichtig bleibt:
TypedDictvalidiert ein Dictionary nicht zur Laufzeit.
Nach:
daten = json.loads(text)
ist nicht plötzlich garantiert, dass die geladenen Daten wirklich
SpielerDaten entsprechen.
Die externe Eingabe muss weiterhin geprüft werden.
object und Any sind nicht dasselbe
Beide können zunächst nach:
irgendein Wert
aussehen.
Sie verhalten sich beim Type Checking aber sehr unterschiedlich.
Mit object:
def zeige(
wert: object,
) -> None:
print(wert)
kann jeder Python-Wert übergeben werden.
Innerhalb der Funktion darfst Du aber nicht einfach behaupten, der Wert hätte irgendeine spezielle Methode:
def gross(
wert: object,
) -> str:
return wert.upper()
Ein Type Checker wird das beanstanden.
Du musst den Typ erst eingrenzen:
def gross(
wert: object,
) -> str:
if isinstance(
wert,
str,
):
return wert.upper()
raise TypeError(
"String erwartet"
)
object bedeutet also:
Ich weiß noch nicht, welcher Typ das ist. Prüfe ihn weiter.
Any: Type Checking bewusst verlassen
Any ist wesentlich permissiver:
from typing import Any
def lade_rohdaten() -> Any:
...
Mit:
wert: Any = "viel"
akzeptieren Type Checker sehr viele Operationen:
wert - 3
wert.irgendwas()
wert[500]
Any verbreitet sich leicht durch den Code und kann dadurch große Bereiche der
statischen Prüfung ausschalten.
Es ist trotzdem manchmal notwendig:
- bei untypisierten Drittbibliotheken,
- an sehr dynamischen API-Grenzen,
- während einer schrittweisen Migration,
- bei Framework-Code, den der Checker nicht ausreichend versteht.
Eine gute Strategie ist:
Anymöglichst am Rand halten und Werte früh in bekannte Typen überführen.
Wenn Du wirklich sagen willst:
Hier kann jeder Wert stehen, aber ich weiß noch nichts über ihn.
ist object häufig sicherer als Any.
cast(): dem Checker etwas versprechen
Manchmal weißt Du etwas, was der Type Checker nicht herleiten kann.
from typing import cast
wert: object = "Karl"
name = cast(
str,
wert,
)
Der Checker behandelt name anschließend als str.
Zur Laufzeit führt cast() allerdings keine Typprüfung durch.
Das hier ist deshalb gefährlich:
wert: object = 123
name = cast(
str,
wert,
)
print(
name.upper()
)
cast() verwandelt den Integer nicht in einen String.
Du hast dem Checker lediglich versprochen, dass Du weißt, was Du tust.
Wenn eine echte Prüfung möglich ist:
if isinstance(
wert,
str,
):
print(
wert.upper()
)
ist sie meistens besser.
Narrowing mit isinstance()
Type Checker verstehen viele normale Python-Prüfungen:
def normalisiere(
wert: int | str,
) -> int:
if isinstance(
wert,
int,
):
return wert
return int(wert)
Vor der Prüfung:
int | str
Im if-Block:
int
Im übrigen Pfad:
str
Auch Prüfungen auf None oder bestimmte Literals können den Typ verengen.
Du brauchst dafür keine speziellen Typing-APIs.
Eigene Narrowing-Funktionen mit TypeIs
Manchmal steckt dieselbe Typprüfung in einer Hilfsfunktion.
Seit Python 3.13 gibt es dafür TypeIs:
from typing import TypeIs
def ist_gegenstand(
wert: object,
) -> TypeIs[Gegenstand]:
return isinstance(
wert,
Gegenstand,
)
Nun:
wert: object = lade_wert()
if ist_gegenstand(wert):
print(
wert.name
)
Der Checker versteht im if-Block, dass wert ein Gegenstand ist.
Eine TypeIs-Funktion muss wirklich eine korrekte Type Predicate
implementieren. Wenn Du dort falsche Versprechen machst, kann der Checker darauf
basierend falsche Schlüsse ziehen.
Für einen einfachen direkten isinstance()-Check brauchst Du TypeIs nicht.
Es wird interessant, wenn die Prüfung selbst komplexer ist und wiederverwendet werden soll.
TypeGuard gibt es ebenfalls
In Typing-Code aus der Zeit vor Python 3.13 wirst Du häufig TypeGuard sehen:
from typing import TypeGuard
TypeIs und TypeGuard lösen ähnliche Probleme, haben aber unterschiedliche
Narrowing-Regeln.
Für viele neue Predicate-Funktionen ist TypeIs die natürlichere Wahl, wenn der
Zieltyp tatsächlich ein kompatibler Untertyp des Eingangstyps ist.
TypeGuard bleibt wichtig für Fälle, in denen das nicht möglich ist,
beispielsweise bei bestimmten invarianten generischen Collections.
Für den normalen Einstieg musst Du diese Feinheit nicht ständig im Kopf haben.
Python 3.14: Annotationen werden lazy ausgewertet
Hier hat sich in Python 3.14 etwas Grundlegendes geändert.
Bis Python 3.13 wurden normale Annotationen standardmäßig ausgewertet, sobald die entsprechende Definition ausgeführt wurde.
Dadurch konnte so etwas problematisch sein:
def finde_gegenstand(
name: str,
) -> Gegenstand | None:
...
class Gegenstand:
...
Gegenstand existierte zum Zeitpunkt der Funktionsdefinition noch nicht.
Häufig verwendete man deshalb:
def finde_gegenstand(
name: str,
) -> "Gegenstand | None":
...
oder:
from __future__ import annotations
Seit Python 3.14 werden Annotationen standardmäßig lazy ausgewertet.
Die Annotation muss also nicht mehr sofort beim Definieren der Funktion in einen fertigen Runtime-Wert umgewandelt werden.
Forward References sind dadurch wesentlich angenehmer.
Das betrifft vor allem Runtime-Introspection
Für einen normalen statischen Type Checker ist die wichtigste Information weiterhin der Quellcode.
Relevant wird die Änderung, wenn ein Programm Annotationen selbst zur Laufzeit untersucht.
Beispielsweise Frameworks, Serializer oder Dependency-Injection-Systeme.
Statt sich blind auf:
objekt.__annotations__
und dessen historische Details zu verlassen, gibt es in Python 3.14 das neue
Modul annotationlib.
Zum Beispiel:
from annotationlib import (
get_annotations,
)
def funktion(
wert: int,
) -> str:
return str(wert)
print(
get_annotations(
funktion
)
)
Für typische Runtime-Typinformationen ist außerdem weiterhin:
from typing import get_type_hints
wichtig.
Wenn Du Type Hints nur für mypy, Pyright oder einen Editor schreibst, musst Du
Dich mit annotationlib normalerweise nicht beschäftigen.
from __future__ import annotations in Python 3.14
Der Future Import:
from __future__ import annotations
existiert weiterhin.
Unter Python 3.14 führt er aber nicht zum neuen normalen Lazy-Modell, sondern verwendet weiterhin die ältere stringifizierte Darstellung von Annotationen.
Wenn Du ein bestehendes Projekt damit hast, musst Du ihn nicht hektisch entfernen.
Du solltest aber wissen, dass:
Python 3.14 ohne Future Import
und:
Python 3.14 mit from __future__ import annotations
bei Runtime-Introspection unterschiedliche Annotation-Semantik besitzen.
Für reine statische Prüfung fällt das im Alltag wesentlich weniger auf.
Type Hints im Dungeon
Einige Funktionen unseres Dungeon-Projekts könnten so aussehen:
from pathlib import Path
type RaumId = str
def frage_ganzzahl(
text: str,
minimum: int | None = None,
) -> int:
...
def teile_befehl(
eingabe: str,
) -> tuple[str, str]:
...
def finde_raeume_mit_loot(
raeume: dict[RaumId, Raum],
) -> list[str]:
...
def speichere_spielstand(
spieler: Spieler,
ort: RaumId,
raeume: dict[RaumId, Raum],
besuchte_raeume: set[RaumId],
speicherdatei: Path,
) -> None:
...
def lade_spielstand(
spieler: Spieler,
raeume: dict[RaumId, Raum],
speicherdatei: Path,
) -> tuple[RaumId, set[RaumId]]:
...
Die Signaturen zeigen bereits ziemlich viel über das Programm.
Für:
lade_spielstand(...)
ist sofort klar:
welche Werte hineingehen
welche Collections erwartet werden
welcher Pfadtyp verwendet wird
was zurückkommt
Das ist einer der größten praktischen Vorteile von Type Hints.
Ein typisierter Befehl
Statt ein Tuple zurückzugeben, könnten wir das Ergebnis außerdem modellieren:
from dataclasses import dataclass
@dataclass(
frozen=True,
slots=True,
)
class Befehl:
verb: str
argument: str = ""
def teile_befehl(
eingabe: str,
) -> Befehl:
verb, _, argument = (
eingabe
.strip()
.lower()
.partition(" ")
)
if not verb:
raise ValueError(
"Bitte gib einen Befehl ein."
)
return Befehl(
verb=verb,
argument=argument.strip(),
)
Verwendung:
befehl = teile_befehl(
"nimm fackel"
)
print(
befehl.verb
)
print(
befehl.argument
)
Der Checker weiß:
teile_befehl erwartet str
teile_befehl liefert Befehl
Befehl.verb ist str
Befehl.argument ist str
Dieser Aufruf kann deshalb bereits statisch auffallen:
teile_befehl(123)
Type Checker finden Fehler ohne Programmlauf
Annotationen allein erzeugen noch keine Warnung im Terminal.
Dafür brauchst Du einen statischen Type Checker.
Heute gibt es mehrere ernsthafte Optionen:
mypy
Pyright
ty
Pyrefly
Sie folgen weitgehend derselben Python Typing Specification, sind aber unterschiedliche Implementierungen.
Deshalb können sie bei schwierigen Fällen auch zu unterschiedlichen Diagnosen kommen.
Das ist normal.
Die Typing-Welt ist standardisiert, aber nicht jeder Checker implementiert jede neue Funktion zum exakt gleichen Zeitpunkt oder trifft bei unvollständigen Informationen dieselben Entscheidungen.
mypy
mypy ist einer der ältesten und am weitesten verbreiteten statischen Type Checker für Python.
Mit uv:
uv add --dev mypy
Dann:
uv run mypy .
Bei einem installierbaren src-Projekt kannst Du auch gezielter prüfen:
uv run mypy src
Ein Beispiel:
def erleide_schaden(
hp: int,
menge: int,
) -> int:
return max(
hp - menge,
0,
)
erleide_schaden(
"viel",
3,
)
mypy meldet sinngemäß:
Argument 1 ... has incompatible type "str"; expected "int"
Die genaue Meldung und der Error Code hängen von der mypy-Version ab.
mypy konfigurieren
Die Konfiguration kann in pyproject.toml stehen:
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
check_untyped_defs = true
python_version sollte dabei normalerweise die Python-Version beschreiben, für
die Dein Code geprüft werden soll.
Wenn Dein Projekt beispielsweise:
requires-python = ">=3.12"
angibt und tatsächlich Python 3.12 unterstützen soll, ist:
python_version = "3.12"
sinnvoller als einfach die zufällig lokal installierte Python-3.14-Version einzutragen.
So kann der Checker verhindern, dass Du versehentlich APIs verwendest, die erst später eingeführt wurden.
Unannotierte Funktionen bei mypy
mypy behandelt komplett unannotierte Funktionen bewusst lockerer.
Nehmen wir:
def verdopple(wert):
return wert + "2"
Ohne zusätzliche Einstellungen wird der Funktionskörper nicht so streng analysiert wie bei einer vollständig annotierten Funktion.
Mit:
check_untyped_defs = true
prüft mypy auch die Körper solcher Funktionen stärker, ohne sofort vollständige Annotationen für jede Signatur zu verlangen.
Für ein bestehendes Projekt ist das ein brauchbarer Zwischenschritt.
Strenger wäre:
disallow_untyped_defs = true
Dann werden unannotierte Funktionsdefinitionen selbst zum Problem.
mypy strict
mypy bündelt viele strengere Regeln in:
uv run mypy --strict .
Oder:
[tool.mypy]
strict = true
Das ist für ein neues, bewusst typisiertes Projekt eine gute Option.
Bei einem großen alten Codebestand kann es aber hunderte Meldungen auf einmal erzeugen.
Dann ist eine schrittweise Einführung meist sinnvoller:
zuerst wichtige Module annotieren
check_untyped_defs aktivieren
Any reduzieren
strengere Regeln nach und nach einschalten
Du kannst mypy-Einstellungen auch pro Modul unterschiedlich streng gestalten.
Ein Projekt muss nicht an einem Nachmittag von:
praktisch untypisiert
zu:
strict überall
springen.
Pyright
Pyright ist ein statischer Type Checker von Microsoft.
Viele VS-Code-Nutzer begegnen ihm indirekt über Pylance.
Pylance verwendet Pyright für die Type-Analyse und ergänzt Editor-Funktionen.
Wenn Du in VS Code mit Pylance arbeitest, bekommst Du deshalb bereits sehr viel Type Checking direkt während des Tippens.
Pyright lässt sich auch als CLI verwenden:
pyright
Pyright installieren
Die offizielle Pyright-CLI wird als Node-Package veröffentlicht:
npm install --save-dev pyright
Dann beispielsweise:
npx pyright
Es gibt außerdem ein community-maintained Python-Package, das auch in der offiziellen Pyright-Dokumentation als Installationsweg genannt wird:
python -m pip install pyright
oder mit uv:
uv add --dev pyright
uv run pyright
Dieses PyPI-Package ist ein Wrapper für das eigentliche Node-basierte Pyright und wird nicht direkt von Microsoft gepflegt.
Wenn Du ohnehin Pylance im Editor verwendest, brauchst Du die separate CLI nur, wenn derselbe Check beispielsweise auch in CI laufen soll.
Pyright konfigurieren
Pyright versteht eine:
pyrightconfig.json
kann aber auch aus pyproject.toml lesen.
Zum Beispiel:
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "standard"
include = [
"src",
"tests",
]
Die verfügbaren Modi sind:
off
basic
standard
strict
standard ist der aktuelle Default.
Für ein neues Projekt kannst Du natürlich direkt ausprobieren:
typeCheckingMode = "strict"
und einzelne Regeln wieder abschwächen, wenn sie für das Projekt nicht passen.
mypy und Pyright verhalten sich nicht identisch
Ein wichtiger Unterschied steckt beispielsweise in unannotierten Funktionen.
Pyright analysiert deren Bodies standardmäßig.
Bei mypy musst Du dafür bei komplett unannotierten Funktionen unter anderem:
check_untyped_defs = true
aktivieren.
Auch Typinferenz, Unknown/Any, Import-Auflösung und einzelne neue
Typing-Features können sich unterscheiden.
Darum ist:
mypy meldet nichts
nicht dasselbe wie:
Dieser Code ist nach jeder denkbaren Interpretation typkorrekt.
Ein Checker ist ein Werkzeug mit konkreten Regeln.
Brauche ich mypy und Pyright gleichzeitig?
Normalerweise nicht.
Ein Projekt kann selbstverständlich beide laufen lassen:
mypy .
pyright
Sie finden gelegentlich unterschiedliche Dinge.
Du bezahlst dafür aber mit:
zwei Konfigurationen
zwei Arten von Ignore-Kommentaren
unterschiedlichen Diagnosen
mehr CI-Zeit
mehr Pflege
Für die meisten Projekte ist es besser, einen Checker konsequent zu verwenden.
Wenn das Team ohnehin Pylance nutzt, ist Pyright naheliegend.
Wenn bestehende Libraries, CI und Konfiguration bereits auf mypy ausgerichtet sind, gibt es keinen Grund, das nur aus Modegründen zu ändern.
ty
ty stammt von Astral, den Entwicklern hinter Ruff und uv.
Es ist in Rust geschrieben und kombiniert:
Type Checker
Language Server
Der CLI-Aufruf ist:
ty check
Mit uv kannst Du es schnell ausprobieren:
uvx ty check
Oder als Development Dependency festhalten:
uv add --dev ty
uv run ty check
Für ein Team-Projekt ist die zweite Variante reproduzierbarer.
ty ist inzwischen mehr als ein Experiment
ty befindet sich weiterhin in der Beta-Phase und entwickelt sich schnell.
Astral empfiehlt es inzwischen aber ausdrücklich auch motivierten Nutzern für Production-Projekte.
Das ist ein anderer Stand als bei den frühen Alpha-Versionen, bei denen noch deutlich vor produktiver Nutzung gewarnt wurde.
Trotzdem existieren weiterhin Unterschiede zur vollständigen Abdeckung von mypy und Pyright.
Wenn Dein Projekt spezielle Typing-Konstrukte oder Frameworks verwendet, würde ich eine Migration deshalb mit dem eigenen Codebestand testen und nicht nur anhand kleiner Beispiele entscheiden.
ty konfigurieren
ty liest Konfiguration aus:
pyproject.toml
unter:
[tool.ty]
oder aus einer eigenen:
ty.toml
Einzelne Regeln lassen sich beispielsweise konfigurieren:
[tool.ty.rules]
possibly-unresolved-reference = "warn"
division-by-zero = "ignore"
Die möglichen Severities sind:
ignore
warn
error
Interessant ist auch, dass ty den Python-Zielstand normalerweise aus dem unteren Limit von:
[project]
requires-python = ">=3.12"
ableiten kann.
Du musst die Python-Version also in vielen uv-Projekten nicht noch einmal separat wiederholen.
ty prüft unannotierte Funktionen anders
Im Gegensatz zu mypy analysiert ty auch Bodies unannotierter Funktionen grundsätzlich.
Es gibt deshalb kein direktes ty-Gegenstück zu:
mypy --check-untyped-defs
Das gehört bereits zum normalen Verhalten.
Auch bei der Strictness funktioniert ty nicht einfach wie:
mypy strict
oder:
Pyright strict
Fast alle normalen ty-Regeln sind bereits standardmäßig aktiv. Zusätzliche Regeln lassen sich gezielt einschalten.
Das ist ein gutes Beispiel dafür, warum man die Konfiguration verschiedener Checker nicht eins zu eins übersetzen sollte.
Pyrefly
Pyrefly ist ein Type Checker und Language Server von Meta.
Der CLI-Aufruf:
pyrefly check
Mit uv:
uvx pyrefly check
oder als Development Dependency:
uv add --dev pyrefly
uv run pyrefly check
Pyrefly hat im Mai 2026 die Version 1.0 erreicht.
Das Projekt bezeichnet die 1.x-Linie ausdrücklich als production-ready.
Damit ist die alte Beschreibung:
interessantes neues Tool, besser erst einmal beobachten
inzwischen überholt.
Pyrefly konfigurieren
Eine Konfiguration kann in:
pyrefly.toml
liegen.
Oder in der pyproject.toml:
[tool.pyrefly]
Beispielsweise:
[tool.pyrefly]
python-version = "3.12"
Pyrefly besitzt unterschiedliche Presets für die gewünschte Strenge und kann seine Konfiguration schrittweise an ein Projekt anpassen.
Interessant bei Migrationen: Wenn keine eigene Pyrefly-Konfiguration vorhanden ist, kann Pyrefly vorhandene mypy- oder Pyright-Konfiguration teilweise als Ausgangspunkt berücksichtigen.
Trotzdem solltest Du das Ergebnis einer Migration prüfen. Die Tools besitzen nicht exakt dieselben Regeln.
Welchen Checker würde ich heute nehmen?
Für einen Einsteiger gibt es keine eindeutige universelle Antwort.
mypy hat den Vorteil einer riesigen Menge bestehender Dokumentation, Erfahrung und Projektkonfigurationen.
Pyright ist besonders attraktiv, wenn Du ohnehin Pylance und VS Code nutzt und schnelles Feedback direkt im Editor möchtest.
ty passt gut in einen Astral-Workflow mit uv und Ruff, ist sehr schnell und inzwischen ernsthaft produktiv einsetzbar, befindet sich aber weiterhin in aktiver Beta-Entwicklung.
Pyrefly ist ebenfalls sehr schnell, seit Version 1.0 offiziell stabil und inzwischen eine vollwertige weitere Option.
Für unseren Lern-Dungeon würde ich nicht vier Checker parallel konfigurieren.
Ich würde einen auswählen, ihn sauber in Editor und CI integrieren und erst wechseln, wenn es einen konkreten Grund dafür gibt.
Type Checker in der CI
Ein Type Checker wird besonders wertvoll, wenn er nicht nur gelegentlich auf einem Entwicklerrechner läuft.
Mit mypy beispielsweise:
uv sync --locked
uv run --locked ruff check .
uv run --locked ruff format --check .
uv run --locked pytest
uv run --locked mypy .
Oder mit ty:
uv run --locked ty check
Mit Pyrefly:
uv run --locked pyrefly check
Wenn Du Pyright als Development Dependency über den Python-Wrapper verwaltest:
uv run --locked pyright
Der Checker sollte zum festen Projektworkflow gehören.
Sonst passiert schnell:
Lokal läuft er manchmal.
CI interessiert sich nicht dafür.
Neue Warnungen sammeln sich über Monate an.
Irgendwann ist niemand mehr bereit, sie alle zu beheben.
Type Hints und Tests prüfen unterschiedliche Dinge
Type Hints ersetzen keine Tests.
Diese Funktion ist vollständig typkorrekt:
def erleide_schaden(
hp: int,
menge: int,
) -> int:
return hp + menge
Integer hinein:
int
int
Integer hinaus:
int
Der Type Checker ist zufrieden.
Fachlich ist die Funktion trotzdem falsch.
Ein Test findet das:
def test_schaden_reduziert_hp():
assert (
erleide_schaden(
100,
30,
)
== 70
)
Type Checker beantworten eher:
Passen die verwendeten Typen zusammen?
Tests beantworten:
Tut der Code fachlich das, was wir erwarten?
Beides ergänzt sich.
Mehr dazu findest Du in pytest von Null.
Type Hints und Runtime-Validierung
Daten von außen sind nicht vertrauenswürdig, nur weil eine Variable annotiert wurde.
Angenommen:
def lade_spielstand(
daten: SpielerDaten,
) -> Spieler:
...
Dieser Type Hint bedeutet:
Aufrufer sollen hier
SpielerDatenübergeben.
Nach:
daten = json.loads(text)
weiß Python aber noch nicht, ob das tatsächlich stimmt.
Die Datei könnte enthalten:
{
"name": 123,
"hp": "sehr viel"
}
Ein Type Checker kann den Inhalt einer zur Laufzeit eingelesenen fremden Datei nicht vorhersagen.
Deshalb brauchst Du an solchen Grenzen weiterhin Checks:
if not isinstance(
daten,
dict,
):
raise SpielstandError(
"Ungültiger Spielstand."
)
und anschließend die Prüfung der erwarteten Felder.
Alternativ kann ein dafür vorgesehenes Validation-Tool diese Aufgabe übernehmen.
Die wichtige Trennung lautet:
Type Hints
statische Erwartungen an Deinen Code
Runtime-Validierung
Kontrolle tatsächlich eintreffender Daten
Type Informationen von Drittbibliotheken
Ein Type Checker kann nur mit Informationen arbeiten, die er besitzt.
Viele Libraries liefern ihre Annotationen inzwischen direkt mit.
Andere stellen separate Stub-Dateien mit der Endung:
.pyi
bereit.
Das Python-Typing-Ökosystem verwendet dafür unter anderem den Standard aus PEP 561.
Eine typisierte Library kann beispielsweise eine Datei py.typed mitliefern,
um zu kennzeichnen, dass ihre Inline-Annotationen für statische Checker gedacht
sind.
Fehlen Typinformationen vollständig, landet ein Checker je nach Tool und Konfiguration häufiger bei:
Any
Unknown
fehlende Stubs
unaufgelöste Typen
Das ist einer der Gründe, warum derselbe eigene Code je nach Dependencies sehr unterschiedlich angenehm zu typisieren sein kann.
Ignore-Kommentare sparsam verwenden
Manchmal liegt der Checker falsch oder kann einen dynamischen Sonderfall nicht verstehen.
Bei mypy gibt es beispielsweise:
wert = externe_funktion() # type: ignore
Besser ist ein möglichst gezielter Ignore mit Error Code:
wert = externe_funktion() # type: ignore[assignment]
Pyright besitzt eigene Diagnosecodes:
wert = externe_funktion() # pyright: ignore[reportAssignmentType]
ty entsprechend:
wert = externe_funktion() # ty: ignore[invalid-assignment]
Die genaue Regel hängt natürlich vom jeweiligen Fehler ab.
Ein Ignore ist kein Problem an sich.
Problematisch wird:
# type: ignore
als reflexartige Antwort auf jede Meldung.
Dann verwandelt sich der Type Checker langsam in ein Werkzeug, das hauptsächlich ignoriert wird.
Typen schrittweise einführen
Ein bestehendes Projekt komplett auf einmal zu typisieren, ist selten nötig.
Für unseren Dungeon könnte der Einstieg so aussehen.
Zuerst reine Funktionen:
def teile_befehl(
eingabe: str,
) -> tuple[str, str]:
...
Dann Eingabehelfer:
def frage_ganzzahl(
text: str,
minimum: int | None = None,
) -> int:
...
Danach öffentliche Funktionen:
def speichere_spielstand(
spieler: Spieler,
ort: RaumId,
raeume: Mapping[RaumId, Raum],
besuchte_raeume: set[RaumId],
speicherdatei: Path,
) -> None:
...
Und die zentralen Datenmodelle:
@dataclass
class Spieler:
name: str
hp: int
max_hp: int
gold: int
Je mehr klare Typen vorhanden sind, desto mehr kann der Checker selbst herleiten.
Du musst deshalb nicht jede lokale Variable annotieren.
Öffentliche Grenzen zuerst
Wenn Du nur wenig Zeit investieren möchtest, haben Type Hints an Funktionsgrenzen meist den größten Nutzen:
def lade_spielstand(
pfad: Path,
) -> Spielstand:
...
Damit dokumentierst Du gleichzeitig:
Was erwartet die Funktion?
Was liefert sie?
Lokale Variablen können Checker häufig selbst inferieren.
Diese Signatur:
def lade_spielstand(pfad):
...
lässt dagegen fast alles offen.
Typen sollten Code verständlicher machen
Das hier:
def finde(
name: str,
) -> Gegenstand | None:
...
hilft sofort.
Wenn eine Signatur dagegen zu einem halben Typing-Programm wird:
Callable[
[
Mapping[
str,
Sequence[
tuple[
str,
int | None,
]
],
],
],
Awaitable[
Mapping[str, object]
],
]
ist es vielleicht Zeit für:
Type Alias
Protocol
TypedDict
Dataclass
eigene Klasse
Typen sind Teil der Lesbarkeit des Codes.
Sie sollten nicht zum Rätsel werden.
Häufige Stolperfallen
-
Type Hints als Runtime-Prüfung verstehen: Ein
name: strverhindert nicht automatisch, dass zur Laufzeit ein Integer ankommt. -
Jede offensichtliche Variable annotieren:
hp: int = 100ist nicht schädlich, aber der Checker hätteinthier auch selbst erkannt. -
Überall konkrete Collections verlangen: Wenn eine Funktion nur liest oder iteriert, können
Sequence,IterableoderMappingbesser zum Vertrag passen. -
Immer den allgemeinsten Typ nehmen: Auch
Iterable[str]kann zu breit sein. Ein einzelnerstrerfüllt beispielsweise ebenfalls dieses Protokoll. -
Optionalals „Parameter darf fehlen“ verstehen:str | Nonesagt nur, welche Werte erlaubt sind. Ob ein Argument weggelassen werden darf, entscheidet der Default. -
Type Aliases mit neuen Typen verwechseln:
type RaumId = strerzeugt keine eigene String-Klasse und trennt Raum- von Gegenstands-IDs statisch nicht streng. -
NewTypemit einer echten Runtime-Klasse verwechseln: Es erzeugt eine statische Unterscheidung, aber keinen vollwertigen neuen Datentyp. -
Literalals Runtime-Validierung ansehen: Ein Type Checker kann"brutal"ablehnen. Eine JSON-Datei kann diesen String trotzdem zur Laufzeit liefern. -
Generische Syntax verwenden, obwohl das Projekt Python 3.11 unterstützt:
def funktion[T](...)undclass Klasse[T]benötigen Python 3.12 oder neuer. -
TypedDictfür einen Validator halten: Zur Laufzeit bleibt das Objekt ein normales Dictionary. -
Anyverwenden, obwohlobjectreicht:Anyschaltet viele Prüfungen ab.objectzwingt Dich, unbekannte Werte vor spezieller Nutzung einzugrenzen. -
cast()für eine Typumwandlung halten:cast(str, wert)macht aus einem Integer keinen String. -
Ein falsches
TypeIsschreiben: Der Checker vertraut dem Predicate. Die Funktion muss deshalb wirklich die behauptete Bedingung prüfen. -
Python-3.14-Lazy-Annotations mit
from __future__ import annotationsgleichsetzen: Der Future Import verwendet weiterhin stringifizierte Annotationen und hat andere Runtime-Semantik. -
Direkt auf
__annotations__bauen: Wenn Du Annotationen zur Laufzeit auswertest, solltest Du die dafür vorgesehenen APIs wieannotationlibodertyping.get_type_hints()kennen. -
Die lokal installierte Python-Version als Checker-Ziel eintragen: Wenn Dein Package ab Python 3.12 laufen soll, sollte der Checker nicht versehentlich ausschließlich Python 3.14 annehmen.
-
mypy ohne Annotationen für streng halten: Komplett unannotierte Funktionen werden standardmäßig weniger tief geprüft.
check_untyped_defskann beim Einstieg helfen. -
Alle Checker für identisch halten: mypy, Pyright, ty und Pyrefly folgen derselben Typing-Welt, besitzen aber unterschiedliche Inferenz- und Diagnoseentscheidungen.
-
Vier Checker gleichzeitig einführen: Meist ist ein konsequent konfigurierter Checker wertvoller als vier halb gepflegte.
-
ty noch als frühes Alpha-Experiment behandeln: Das ist überholt. Das Tool ist Beta und wird von Astral inzwischen auch für motivierte Production-Nutzer empfohlen.
-
Pyrefly weiterhin als experimentellen Pre-1.0-Checker beschreiben: Pyrefly hat seit Mai 2026 eine stabile 1.x-Linie.
-
Checker-Warnungen blind mit Ignore-Kommentaren beseitigen: Ein Ignore sollte eine bewusste Ausnahme sein, kein Ersatz für das Verstehen der Meldung.
-
Type Hints statt Tests verwenden: Typkorrekter Code kann fachlich falsch sein.
-
Daten von außen blind als typisiert behandeln: JSON, CLI-Input, Umgebungsvariablen und Netzwerkdaten brauchen weiterhin Runtime-Validierung.
Kompakte Übersicht
| Ziel | Syntax |
|---|---|
| Parameter | name: str |
| Rückgabewert | -> int |
| kein anderer Rückgabewert | -> None |
| Liste von Strings | list[str] |
| Mapping String → Integer | Mapping[str, int] |
| Set von IDs | set[str] |
| Tuple aus zwei Integern | tuple[int, int] |
| beliebig langes String-Tuple | tuple[str, ...] |
kann None sein |
Raum \| None |
| mehrere Typen | int \| str |
| Type Alias | type RaumId = str |
| statisch neuer Typ | NewType("RaumId", str) |
| feste Werte | Literal["normal", "schwer"] |
strukturierter dict |
TypedDict |
| Funktion als Wert | Callable[[str], int] |
| Verhalten statt Klasse | Protocol |
| eigener Typ der Instanz | Self |
| generische Funktion | def erstes[T](...) -> T |
| generische Klasse | class Beutel[T]: ... |
| unbekannter, aber sicher zu prüfender Wert | object |
| bewusst dynamischer Wert | Any |
| Checker-Versprechen | cast(...) |
| eigenes Narrowing Predicate | TypeIs[T] |
Übungen
1. Eine Funktion annotieren
Annotiere:
def heile(
hp,
menge,
max_hp,
):
return min(
hp + menge,
max_hp,
)
Lösung
def heile(
hp: int,
menge: int,
max_hp: int,
) -> int:
return min(
hp + menge,
max_hp,
)
2. Collections passend annotieren
Die Funktion verändert die Namen nicht und benötigt nur Iteration:
def zeige_namen(namen):
for name in namen:
print(name)
Welcher Typ ist passender als list[str]?
Lösung
from collections.abc import Iterable
def zeige_namen(
namen: Iterable[str],
) -> None:
for name in namen:
print(name)
3. Einen fehlenden Wert ausdrücken
Schreibe eine Funktion:
finde_name(id_)
die entweder einen String oder None zurückgibt.
Lösung
def finde_name(
id_: int,
) -> str | None:
if id_ == 1:
return "Karl"
return None
4. Raum-IDs statisch unterscheiden
Erzeuge einen eigenen NewType namens RaumId auf Basis von str.
Lösung
from typing import NewType
RaumId = NewType(
"RaumId",
str,
)
halle = RaumId(
"halle"
)
def betrete(
raum: RaumId,
) -> None:
...
5. Einen Spielstand mit TypedDict beschreiben
Ein Spieler-Dictionary soll diese Pflichtfelder besitzen:
name: str
hp: int
gold: int
Lösung
from typing import TypedDict
class SpielerDaten(TypedDict):
name: str
hp: int
gold: int
daten: SpielerDaten = {
"name": "Karl",
"hp": 100,
"gold": 50,
}
6. Eine generische Funktion schreiben
Schreibe mit Python 3.12+ eine Funktion, die aus einer Sequence[T] das erste
Element zurückgibt.
Lösung
from collections.abc import Sequence
def erstes[T](
werte: Sequence[T],
) -> T:
return werte[0]
name = erstes(
["Karl", "Lara"]
)
hp = erstes(
[100, 80]
)
7. Narrowing mit TypeIs
Schreibe eine Predicate-Funktion, die prüft, ob ein unbekanntes Objekt ein
Gegenstand ist.
Lösung
from typing import TypeIs
def ist_gegenstand(
wert: object,
) -> TypeIs[Gegenstand]:
return isinstance(
wert,
Gegenstand,
)
wert: object = lade_wert()
if ist_gegenstand(wert):
print(
wert.name
)
8. mypy als Development Dependency
Installiere mypy mit uv und prüfe das Projekt.
Lösung
uv add --dev mypy
uv run mypy .
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
check_untyped_defs = true
9. Einen Typfehler finden
Speichere:
def erleide_schaden(
hp: int,
menge: int,
) -> int:
return max(
hp - menge,
0,
)
erleide_schaden(
"viel",
3,
)
und führe einen Type Checker darüber aus.
Lösung
Mit mypy:uv run mypy typing_demo.py
uvx ty check typing_demo.py
uvx pyrefly check typing_demo.py
Weiterlesen
- Python lernen, Teil 11: Werkzeugkasten und Ausblick
- pytest von Null
- Vom Skript zum echten CLI
- Dataclasses: Klassen ohne Boilerplate
- uv: pip und venv in schnell
- Python-Dokumentation:
typing - Python-Dokumentation: Annotationen in Python 3.14
- Python-Dokumentation:
annotationlib - Python Typing Specification
- PEP 561: Distributing and Packaging Type Information
- PEP 673:
Self - PEP 695: Type Parameter Syntax
- PEP 742:
TypeIs - mypy-Dokumentation
- mypy: Type hints cheat sheet
- mypy: Existing codebases
- Pyright auf GitHub
- Pyright: Installation
- Pyright: Configuration
- Pylance auf GitHub
- ty-Dokumentation
- ty: Von mypy oder Pyright wechseln
- Pyrefly-Dokumentation
- Pyrefly: Configuration
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.