Zum Inhalt springen

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 str oder None sein.

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 str akzeptiert und einen int zurü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:

TypedDict validiert 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:

Any mö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: str verhindert nicht automatisch, dass zur Laufzeit ein Integer ankommt.

  • Jede offensichtliche Variable annotieren: hp: int = 100 ist nicht schädlich, aber der Checker hätte int hier auch selbst erkannt.

  • Überall konkrete Collections verlangen: Wenn eine Funktion nur liest oder iteriert, können Sequence, Iterable oder Mapping besser zum Vertrag passen.

  • Immer den allgemeinsten Typ nehmen: Auch Iterable[str] kann zu breit sein. Ein einzelner str erfüllt beispielsweise ebenfalls dieses Protokoll.

  • Optional als „Parameter darf fehlen“ verstehen: str | None sagt nur, welche Werte erlaubt sind. Ob ein Argument weggelassen werden darf, entscheidet der Default.

  • Type Aliases mit neuen Typen verwechseln: type RaumId = str erzeugt keine eigene String-Klasse und trennt Raum- von Gegenstands-IDs statisch nicht streng.

  • NewType mit einer echten Runtime-Klasse verwechseln: Es erzeugt eine statische Unterscheidung, aber keinen vollwertigen neuen Datentyp.

  • Literal als 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](...) und class Klasse[T] benötigen Python 3.12 oder neuer.

  • TypedDict für einen Validator halten: Zur Laufzeit bleibt das Objekt ein normales Dictionary.

  • Any verwenden, obwohl object reicht: Any schaltet viele Prüfungen ab. object zwingt Dich, unbekannte Werte vor spezieller Nutzung einzugrenzen.

  • cast() für eine Typumwandlung halten: cast(str, wert) macht aus einem Integer keinen String.

  • Ein falsches TypeIs schreiben: Der Checker vertraut dem Predicate. Die Funktion muss deshalb wirklich die behauptete Bedingung prüfen.

  • Python-3.14-Lazy-Annotations mit from __future__ import annotations gleichsetzen: 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 wie annotationlib oder typing.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_defs kann 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"
)
Eine Funktion kann anschließend ausdrücklich:
def betrete(
    raum: RaumId,
) -> None:
    ...
erwarten.

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
Dann:
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]
Damit kann der Checker aus:
name = erstes(
    ["Karl", "Lara"]
)
`str` ableiten und aus:
hp = erstes(
    [100, 80]
)
`int`.

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,
    )
Verwendung:
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 .
Eine mögliche Einstiegskonfiguration:
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
check_untyped_defs = true
Die eingetragene Python-Version sollte zu Deinem tatsächlichen Support-Versprechen passen.

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
Mit ty:
uvx ty check typing_demo.py
Mit Pyrefly:
uvx pyrefly check typing_demo.py
Der Checker sollte erkennen, dass das erste Argument nicht zur erwarteten `int`-Signatur passt.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!