A Coding Implementation on Pyright Type Checking Covering Generics, Protocols, Strict Mode, Type Narrowing, and Modern Python Typing

In this tutorial, we explore Pyright, Microsoft’s high-performance static type checker for Python, and walk through its most powerful features in a hands-on, Colab-friendly format. We start from the ground up with basic annotations and type inference, then progressively advance through Union types, type narrowing, generics, Protocols, TypedDict, dataclasses, and modern typing constructs like Self, TypeAlias, and NewType. We also examine how strict mode raises the bar for type safety across an entire codebase and how pyrightconfig.json provides fine-grained control over diagnostic rules at the project level. Also, we deliberately introduce both correct and intentionally broken code to see exactly how Pyright catches real-world mistakes before they ever reach runtime.

import subprocess, sys, json, textwrap, os

subprocess.check_call([sys.executable, “-m”, “pip”, “install”, “pyright”, “-q”])
print(” pyright installedn”)

WORK = “/tmp/pyright_tutorial”
os.makedirs(WORK, exist_ok=True)

def write(filename: str, code: str) -> str:
path = os.path.join(WORK, filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, “w”) as f:
f.write(textwrap.dedent(code))
return path

def run_pyright(*files, mode=”basic”, extra_flags=None):
args = [sys.executable, “-m”, “pyright”,
“–outputjson”,
f”–pythonversion=3.11″,
f”–typeCheckingMode={mode}”,
*(extra_flags or []),
*[os.path.join(WORK, f) for f in files]]
result = subprocess.run(args, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
print(result.stdout or result.stderr)
return
diags = data.get(“generalDiagnostics”, [])
summary = data.get(“summary”, {})
print(f” errors={summary.get(‘errorCount’,0)} “
f”warnings={summary.get(‘warningCount’,0)} “
f”infos={summary.get(‘informationCount’,0)}”)
for d in diags:
sev = d[“severity”].upper()
msg = d[“message”]
rule = d.get(“rule”, “”)
line = d[“range”][“start”][“line”] + 1
fname = os.path.basename(d[“file”])
tag = f”[{rule}]” if rule else “”
print(f” {sev} {fname}:{line} — {msg} {tag}”)
if not diags:
print(” (no diagnostics — all clean )”)
print()

print(“=” * 62)
print(“SECTION 1 · Basic annotations & inference”)
print(“=” * 62)

write(“s1_basics.py”, “””
def add(x: int, y: int) -> int:
return x + y

result: int = add(1, 2)

bad: int = “hello”
add(1, “two”)
add(1, 2, 3)

def multiply(a: float, b: float):
return a * b

x: str = multiply(2.0, 3.0)
“””)

print(“→ s1_basics.py (basic mode):”)
run_pyright(“s1_basics.py”)

print(“=” * 62)
print(“SECTION 2 · Optional / Union / PEP 604 syntax”)
print(“=” * 62)

write(“s2_optional_union.py”, “””
from typing import Optional, Union

def greet(name: Optional[str] = None) -> str:
if name is None:
return “Hello, Guest”
return f”Hello, {name}”

greet(“Alice”)
greet(None)
greet(42)

def stringify(val: Union[int, float, bool]) -> str:
return str(val)

stringify(3.14)
stringify(“x”)

def modern(val: int | str | None) -> str:
return “” if val is None else str(val)

modern(10)
modern([])
“””)

print(“→ s2_optional_union.py:”)
run_pyright(“s2_optional_union.py”)

We begin by installing Pyright and setting up two helper functions, write and run_pyright, which we reuse throughout subsequent sections to create typed Python files and parse Pyright’s JSON diagnostic output cleanly. We then move on to the basics of type annotations, deliberately passing incorrect argument types and mismatched assignments to observe exactly how Pyright flags each violation. We close this snippet by exploring Optional, Union, and the modern PEP 604 pipe syntax, confirming that Pyright correctly rejects values that fall outside the declared union of accepted types.

print(“SECTION 3 · Type Narrowing”)
print(“=” * 62)

write(“s3_narrowing.py”, “””
from typing import Union
import re

def process(val: Union[int, str]) -> str:
if isinstance(val, int):
return str(val * 2)
return val.upper()

def safe_len(s: str | None) -> int:
if s is None:
return 0
return len(s)

def must_be_str(val: str | int) -> str:
assert isinstance(val, str), “need string”
return val.lower()

from typing import TypeGuard

def is_list_of_str(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)

def join_if_strings(lst: list[object]) -> str:
if is_list_of_str(lst):
return “, “.join(lst)
return “”

from typing import Literal, Never

def assert_never(x: Never) -> Never:
raise AssertionError(f”Unhandled: {x!r}”)

Status = Literal[“ok”, “error”, “pending”]

def handle(s: Status) -> str:
match s:
case “ok”: return “all good”
case “error”: return “something failed”
case “pending”: return “waiting…”
“””)

print(“→ s3_narrowing.py:”)
run_pyright(“s3_narrowing.py”)

print(“=” * 62)
print(“SECTION 4 · Generics, TypeVar, ParamSpec”)
print(“=” * 62)

write(“s4_generics.py”, “””
from typing import TypeVar, Generic, Callable, ParamSpec, Concatenate
from collections.abc import Iterator

T = TypeVar(“T”)
S = TypeVar(“S”)
P = ParamSpec(“P”)

def first(lst: list[T]) -> T:
return lst[0]

x: int = first([1, 2, 3])
y: str = first([“a”, “b”])
z: int = first([“a”, “b”])

class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []

def push(self, item: T) -> None:
self._items.append(item)

def pop(self) -> T:
return self._items.pop()

def peek(self) -> T | None:
return self._items[-1] if self._items else None

int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push(“oops”)

def logged(fn: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
print(f”calling {fn.__name__}”)
result = fn(*args, **kwargs)
print(f”returned {result!r}”)
return result
return wrapper

@logged
def add(a: int, b: int) -> int:
return a + b

add(1, 2)
add(“a”, “b”)

Num = TypeVar(“Num”, int, float)

def double(x: Num) -> Num:
return x * 2 # type: ignore[operator]

double(3)
double(3.14)
double(“hi”)
“””)

print(“→ s4_generics.py:”)
run_pyright(“s4_generics.py”)

We dedicate this snippet first to type narrowing, one of Pyright’s most impressive capabilities, where we demonstrate how control-flow constructs such as isinstance, assert, TypeGuard, and structural match progressively refine a variable’s type within each branch. We then shift to generics, where we build a reusable Stack[T] class and a first() function to demonstrate how TypeVar enables type-safe code that works across multiple concrete types without sacrificing inference. We also introduce ParamSpec, which allows us to wrap functions with decorators while fully preserving the original callable’s argument and return type signatures.

print(“=” * 62)
print(“SECTION 5 · Protocols & Structural Subtyping”)
print(“=” * 62)

write(“s5_protocols.py”, “””
from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: …
def area(self) -> float: …

class Circle:
def __init__(self, r: float) -> None:
self.r = r
def draw(self) -> str:
return f”○ r={self.r}”
def area(self) -> float:
return 3.14159 * self.r ** 2

class Rectangle:
def __init__(self, w: float, h: float) -> None:
self.w, self.h = w, h
def draw(self) -> str:
return f”▭ {self.w}×{self.h}”
def area(self) -> float:
return self.w * self.h

def render(shape: Drawable) -> None:
print(shape.draw(), f”area={shape.area():.2f}”)

render(Circle(5.0))
render(Rectangle(3.0, 4.0))
render(“not a shape”)

from typing import TypeVar, Generic

T_co = TypeVar(“T_co”, covariant=True)

class Readable(Protocol[T_co]):
def read(self) -> T_co: …

class FileStream:
def read(self) -> bytes:
return b”data”

def consume(source: Readable[bytes]) -> bytes:
return source.read()

consume(FileStream())
“””)

print(“→ s5_protocols.py:”)
run_pyright(“s5_protocols.py”)

print(“=” * 62)
print(“SECTION 6 · TypedDict, dataclasses, NamedTuple”)
print(“=” * 62)

write(“s6_datastructures.py”, “””
from typing import TypedDict, NotRequired, Required
from dataclasses import dataclass, field
from typing import NamedTuple

class User(TypedDict):
id: int
name: str
email: NotRequired[str]

u1: User = {“id”: 1, “name”: “Alice”}
u2: User = {“id”: 2, “name”: “Bob”, “email”: “b@x”}
u3: User = {“id”: “bad”, “name”: “Eve”}

def print_user(u: User) -> None:
print(u[“name”], u.get(“email”, “—”))

@dataclass
class Product:
sku: str
price: float
tags: list[str] = field(default_factory=list)
discount: float = 0.0

def final_price(self) -> float:
return self.price * (1 – self.discount)

p = Product(sku=”ABC”, price=9.99)
p.price = 12.50
p.price = “free”

class Point(NamedTuple):
x: float
y: float
label: str = “”

pt = Point(1.0, 2.0)
dist: float = (pt.x ** 2 + pt.y ** 2) ** 0.5
pt.x = 5.0
“””)

print(“→ s6_datastructures.py:”)
run_pyright(“s6_datastructures.py”)

We explore Protocols in this snippet, which provide us with structural subtyping: we can define an interface like Drawable and have Pyright accept any class that implements the required methods, without that class ever explicitly inheriting from the Protocol. We extend this further with a generic Readable[T] Protocol that combines structural checking with type parameters, showing how the two features compose naturally. We then turn to data structures, using TypedDict with NotRequired keys, @dataclass with typed fields, and NamedTuple to demonstrate how Pyright enforces type correctness across Python’s three most common structured-data patterns.

print(“=” * 62)
print(“SECTION 7 · Literal, Final, @overload”)
print(“=” * 62)

write(“s7_literal_final_overload.py”, “””
from typing import Literal, Final, overload

Direction = Literal[“north”, “south”, “east”, “west”]

def move(d: Direction, steps: int = 1) -> str:
return f”Move {d} by {steps}”

move(“north”)
move(“up”)

MAX_RETRIES: Final = 3
MAX_RETRIES = 5

class Config:
DEBUG: Final[bool] = False
VERSION: Final = “1.0.0”

Config.DEBUG = True

@overload
def parse(value: str) -> int: …
@overload
def parse(value: bytes) -> str: …
@overload
def parse(value: int) -> float: …

def parse(value: str | bytes | int) -> int | str | float:
if isinstance(value, str):
return int(value)
if isinstance(value, bytes):
return value.decode()
return float(value)

a: int = parse(“42”)
b: str = parse(b”hi”)
c: float = parse(99)
d: int = parse(99)
“””)

print(“→ s7_literal_final_overload.py:”)
run_pyright(“s7_literal_final_overload.py”)

print(“=” * 62)
print(“SECTION 8 · Strict mode”)
print(“=” * 62)

write(“s8_strict.py”, “””
def no_annotation(x, y):
return x + y

class Bare:
value = None

def partial(x: int, y):
return x
“””)

print(“→ s8_strict.py (strict mode):”)
run_pyright(“s8_strict.py”, mode=”strict”)

We use Literal types here to restrict a function’s argument to a fixed set of string values, and we pair that with Final to show how Pyright prevents reassignment of constants at both the module and class level. We introduce @overload to define multiple distinct call signatures for a single function, allowing Pyright to resolve the correct return type based on the exact argument type the caller provides. We then switch to strict mode and run a deliberately under-annotated file through Pyright, which reveals just how many additional rules, missing return types, untyped parameters, and implicit Any strict mode activates compared to the default.

print(“=” * 62)
print(“SECTION 9 · pyrightconfig.json”)
print(“=” * 62)

config = {
“include”: [“src”],
“exclude”: [“**/__pycache__”],
“pythonVersion”: “3.11”,
“typeCheckingMode”: “strict”,
“reportMissingImports”: “error”,
“reportMissingTypeStubs”: “warning”,
“reportUnknownVariableType”: “warning”,
“reportUnknownMemberType”: “warning”,
“reportUnnecessaryTypeIgnoreComment”: “warning”,
}
cfg_path = os.path.join(WORK, “pyrightconfig.json”)
with open(cfg_path, “w”) as f:
json.dump(config, f, indent=2)
print(f”Written: {cfg_path}”)
print(json.dumps(config, indent=2))
print()

print(“=” * 62)
print(“SECTION 10 · Self, TypeAlias, NewType”)
print(“=” * 62)

write(“s10_modern_types.py”, “””
from typing import Self, TypeAlias, NewType

class Query:
def __init__(self) -> None:
self._filters: list[str] = []

def where(self, cond: str) -> Self:
self._filters.append(cond)
return self

def build(self) -> str:
return ” AND “.join(self._filters)

class AdvancedQuery(Query):
def order_by(self, col: str) -> Self:
return self

q = AdvancedQuery().where(“age > 18”).order_by(“name”)
reveal_type(q)

Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]

def dot(a: Vector, b: Vector) -> float:
return sum(x * y for x, y in zip(a, b))

v1: Vector = [1.0, 2.0, 3.0]
v2: Vector = [4.0, 5.0, 6.0]
dot(v1, v2)
dot(v1, [1, 2, 3])

UserId = NewType(“UserId”, int)
OrderId = NewType(“OrderId”, int)

def get_user(uid: UserId) -> str:
return f”user_{uid}”

uid = UserId(42)
oid = OrderId(99)

get_user(uid)
get_user(oid)
get_user(42)
“””)

print(“→ s10_modern_types.py:”)
run_pyright(“s10_modern_types.py”)

print(“=” * 62)
print(“SECTION 11 · reveal_type() & type: ignore”)
print(“=” * 62)

write(“s11_reveal_ignore.py”, “””
from typing import Any

values = [1, “two”, 3.0]
reveal_type(values)

def mystery(x: Any) -> Any:
return x

r = mystery(42)
reveal_type(r)

bad: int = “oops”
bad2: int = “also bad” # type: ignore[assignment]
“””)

print(“→ s11_reveal_ignore.py:”)
run_pyright(“s11_reveal_ignore.py”)

print(“=” * 62)
print(“TUTORIAL COMPLETE”)
print(“=” * 62)
print(“””
Topics covered
──────────────
1 Basic annotations & inference
2 Optional / Union / PEP 604 syntax
3 Type narrowing (isinstance, guards, TypeGuard, match)
4 Generics — TypeVar, Generic, ParamSpec
5 Protocols & structural subtyping
6 TypedDict, dataclasses, NamedTuple
7 Literal, Final, @overload
8 Strict mode
9 pyrightconfig.json
10 Self, TypeAlias, NewType
11 reveal_type() & type: ignore

All source files written to: /tmp/pyright_tutorial/
“””)

We write a pyrightconfig.json file in this snippet to show how we configure Pyright at the project level, enabling strict mode globally and tuning individual diagnostic rules, such as reportMissingImports and reportUnknownMemberType, to either error or warning severity. We then work through three modern typing constructs: Self for fluent builder APIs that return the correct subclass type, TypeAlias for readable type-level naming, and NewType for creating nominally distinct types that Pyright refuses to mix even when their underlying representation is identical. 

In conclusion, we have covered eleven distinct areas of Pyright’s type system and come away with a clear picture of just how much safety and clarity static typing brings to Python codebases of any size. We have seen that Pyright goes well beyond simple annotation checking, it narrows types through control flow, enforces structural contracts through Protocols, preserves callable signatures through ParamSpec, and locks down constants through Final. We also maintained a practical workflow: we write typed code, run Pyright in basic or strict mode, interpret its JSON diagnostic output, and tune behavior via pyrightconfig.json.

Check out the Full Codes with Notebook here. Also, feel free to follow us on Twitter and don’t forget to join our 130k+ ML SubReddit and Subscribe to our Newsletter. Wait! are you on telegram? now you can join us on telegram as well.

Need to partner with us for promoting your GitHub Repo OR Hugging Face Page OR Product Release OR Webinar etc.? Connect with us

The post A Coding Implementation on Pyright Type Checking Covering Generics, Protocols, Strict Mode, Type Narrowing, and Modern Python Typing appeared first on MarkTechPost.

By

Leave a Reply

Your email address will not be published. Required fields are marked *