Control Flow and Collections
Most of Typhon’s day-to-day syntax — branches, loops, lists, dicts — is plain Python. This page focuses on what’s different: guard, how match is treated (covered in full in Sealed Unions and Match), and how collection types are annotated under the non-nullable rules.
if, elif, else
Unchanged from Python:
def classify(score: int) -> str: if score >= 90: return "A" elif score >= 75: return "B" elif score >= 60: return "C" else: return "F"Convention is that every branch either returns or falls through to a converging return. Typhon does enforce this with tyc::missing_return — a function that declares a non-None return type but has a path that falls off the end is a compile error. For example:
def classify(score: int) -> str: if score >= 60: return "pass" # ❌ tyc::missing_return — fix with an else branch or a converging returnSealed-union match (covered later) gives you the same guarantee with a stronger check: even though both forms reject the missing-fallthrough case, exhaustive match proves it at the variant level, so refactors that add a variant point you straight at every handler site.
while
def countdown(n: int) -> None: mut i: int = n while i > 0: print(i) i = i - 1 print("liftoff")The loop variable is mut because we reassign it. The checker would catch a let here with tyc::immutable_assign.
while True: and Never-returning functions (v0.9.0)
A while True: loop whose body always exits via return or raise on every branch is recognised as never falling through. The post-loop point is unreachable, so tyc::missing_return doesn’t fire on the function:
def serve() -> Never: while True: let req: Request = accept() if req.is_quit(): raise SystemExit handle(req) # no return needed here — post-loop point is unreachable since v0.9.0Add a break anywhere in the body and the fall-through check re-engages — loops that exit cleanly still need a post-loop return.
Post-while-loop narrowing (v0.9.0)
After a while loop whose test is “this value is None” and whose body reassigns the binding (no break), the post-loop binding is narrowed to non-None:
def first_loaded(sources: list[Loader]) -> Config: mut cfg: Config? = None mut i: int = 0 while cfg is None: cfg = sources[i].load() i = i + 1 return cfg # ✅ narrowed to Config since v0.9.0This matches the narrowing that pyright, mypy, and pyrefly apply.
for
for iterates anything Iterable. The element type is inferred from the iterable:
def sum_all(xs: list[int]) -> int: mut total: int = 0 for x in xs: total = total + x return totalFor index-and-value pairs, use enumerate. For parallel lists, use zip:
let names: list[str] = ["a", "b", "c"]for i, name in enumerate(names): print(f"{i}: {name}")break and continue
Same semantics as Python. break and continue count as control-flow exits for reachability analysis.
guard — early-return with narrowing
guard binds a value and short-circuits the falsy/None case:
def shipping_cost(weight: float?) -> float: guard w = weight else: return 0.0 return w * 1.25Inside the else: block you must return, raise, or otherwise leave the enclosing function. After the guard, the name (w) is narrowed to its non-null form.
assert x is not None also narrows (v0.9.0)
The standard Python static-checker idiom narrows the binding under assert too:
def display(name: str?) -> str: assert name is not None return name.upper() # narrowed to str since v0.9.0assert is a runtime check that raises AssertionError on the false branch, and Typhon now treats it as a control-flow gate that narrows the binding for the rest of the scope. Use it sparingly — running Python with -O disables assert, so safety-critical narrowing should still go through guard or if x is None: return. assert is best for “this can’t happen” checkpoints inside functions that already validated their inputs.
guard is sugar for:
if weight is None: return 0.0let w: float = weightreturn w * 1.25…but it reads better, and it pushes the failure case up front where it belongs.
Chain guards naturally:
def open_session(token: str?, user_id: int?) -> str: guard t = token else: return "anonymous" guard u = user_id else: return "anonymous" return f"session({t}, {u})"match — preview
match exists, but its power lands in Sealed Unions and Match when you have a sealed union to match against. The basics:
def describe(n: int) -> str: match n: case 0: return "zero" case 1 | 2 | 3: return "small" case _: return "many"For sealed unions, the wildcard (_) becomes optional — the checker enforces exhaustiveness automatically.
Collections
list[T]
Mutable, ordered. The element type is required:
let primes: list[int] = [2, 3, 5, 7, 11]
mut bag: list[str] = []bag.append("hello")A heterogeneous literal is rejected unless the annotation is a union:
let mixed: list[int] = [1, "two"] # ❌ str not assignable to intlet mixed: list[int | str] = [1, "two"] # ✅dict[K, V]
let counts: dict[str, int] = {"apples": 3, "pears": 1}let n: int? = counts.get("apples") # `.get` returns V | Nonedict.get(key) returns V? — there is no implicit None-stripping. Either check it, narrow it, or use dict[key] (which raises KeyError and is typed V).
set[T]
let seen: set[int] = {1, 2, 3}let also_seen: set[int] = set()tuple[...]
Fixed-arity tuples spell the type of every slot:
let point: tuple[float, float] = (1.0, 2.0)let rgb: tuple[int, int, int] = (255, 128, 0)Variable-length homogeneous tuples use tuple[T, ...]:
def average(*nums: float) -> float: # nums has type tuple[float, ...] return sum(nums) / len(nums)Comprehensions
List, set, and dict comprehensions are unchanged:
let nums: list[int] = [1, 2, 3, 4, 5]let squares: list[int] = [n * n for n in nums]let evens: set[int] = {n for n in nums if n % 2 == 0}let by_value: dict[int, int] = {n: n * n for n in nums}Generator expressions exist; their type is Iterator[T].
Iterating safely over optional collections
A common shape: a function returns list[T]? (“none if not found”), and you want to iterate the result. Narrow first:
def first_letters(words: list[str]?) -> list[str]: guard ws = words else: return [] return [w[0] for w in ws if len(w) > 0]The guard narrows words to list[str], so the comprehension type-checks. Without the guard, for w in words would be a “nullable use” error.
Exception handling
try/except is available but rarely the right tool in Typhon — error handling is meant to flow through Result[T, E]. Treat try as the boundary between Python’s exception world and Typhon’s typed-error world:
import json
def parse(raw: str) -> dict[str, int]?: try: return json.loads(raw) except (ValueError, TypeError): return NoneYou’ll typically wrap untyped or third-party calls in a tiny try and lift their failures into Result[T, E] — covered in Error Handling.
Common mistakes
Missing element type on a collection
let xs: list = [1, 2, 3] # ❌ bare `list` is an implicit Any element typeFix: let xs: list[int] = [1, 2, 3].
Treating dict.get(...) as non-nullable
let counts: dict[str, int] = {"a": 1}let n: int = counts.get("missing") # ❌ get() returns int | NoneFix: let n: int? = counts.get("missing"), then narrow.
Reassigning a let loop accumulator
def sum_all(xs: list[int]) -> int: let total: int = 0 for x in xs: total = total + x # ❌ total is `let` return totalFix: mut total: int = 0.
What you’ve learned
- Branches and loops are plain Python, with reachability tracked by the checker.
guardis the idiomatic “fail fast onNone” form, with narrowing afterwards.- Collection annotations are mandatory (
list[T],dict[K, V],set[T],tuple[A, B, ...]). dict.get(k)returnsV?; narrow before use.- Use
try/exceptonly at the boundary;Result[T, E]is the in-language error type.
Where next
- Classes and Models — defining your own types.
- Error Handling — typed errors, the
?operator,with-chains. - Sealed Unions and Match — exhaustive
matchon closed unions.