Skip to content

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 return

Sealed-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.0

Add 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.0

This 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 total

For 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.25

Inside 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.0

assert 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.0
let w: float = weight
return 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 int
let 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 | None

dict.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 None

You’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 type

Fix: 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 | None

Fix: 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 total

Fix: mut total: int = 0.

What you’ve learned

  • Branches and loops are plain Python, with reachability tracked by the checker.
  • guard is the idiomatic “fail fast on None” form, with narrowing afterwards.
  • Collection annotations are mandatory (list[T], dict[K, V], set[T], tuple[A, B, ...]).
  • dict.get(k) returns V?; narrow before use.
  • Use try/except only at the boundary; Result[T, E] is the in-language error type.

Where next