Values and Bindings
Two ideas do most of the safety work in Typhon: bindings are immutable by default (let), and types cannot hold None unless you say so (T?). Everything in this page flows from those two rules. They are Rules 2 and 3 from The Five Rules.
let and mut
A local binding picks one of two keywords:
def demo() -> None: let pi: float = 3.14159 mut counter: int = 0
counter = counter + 1 # ✅ mut is reassignable # pi = 3.14 # ❌ tyc::immutable_assignlet— immutable binding. Reassignment is a compile error.mut— mutable binding. Required for any name you intend to rebind.
This is binding immutability, like Rust’s let vs let mut or TypeScript’s const vs let. A let cannot point at a new object; the object it points at can still have mutable fields. For deep immutability, freeze the underlying dataclass with class Foo frozen: (see Classes and Models).
Why default to let?
Mutability is a hazard you want to opt into, not out of. let is the cheaper-to-read choice for the reader: they don’t have to scan the rest of the function looking for reassignments. Reach for mut only when you actually need it — loop counters, accumulators, builders.
Module-level bindings default to let
PI: float = 3.14159 # implicitly `let`mut feature_flag: bool = False # explicitly mutableInside a function, the kind is always explicit. At module top level, it’s let unless declared otherwise.
What gets emitted
let pi: float = 3.14159mut counter: int = 0pi: float = 3.14159counter: int = 0let / mut are erased at emit time — they’re compile-time markers. The emitted Python looks exactly like what an experienced Pythonista would write.
Primitives
The familiar Python primitives, with stricter rules:
| Type | Example | Notes |
|---|---|---|
int | 42 | Arbitrary precision, like Python. |
float | 3.14 | 64-bit IEEE 754. |
bool | True / False | Not an int for type-checking purposes. |
str | "hello" | UTF-8, identical to Python. |
bytes | b"hi" | Immutable byte sequences. |
None | None | Inhabitant of the unit type; only valid where T? allows it. |
def types() -> None: let n: int = 10 let ratio: float = n / 3 # int → float, allowed let msg: str = f"n={n}" let flag: bool = n > 0int and float are distinct
let x: int = 3.14 # ❌ type mismatch: expected int, found floatlet y: float = 3 # ✅ int widens to floatFloats do not silently truncate to ints. Use int(x) or round(x) explicitly.
bool is not int
In Python, True == 1 and bool is a subclass of int. The checker treats them as distinct:
let n: int = True # ❌ type mismatch: expected int, found boollet n: int = int(True) # ✅ explicit castThis catches the common bug where a boolean accidentally ends up in numeric arithmetic.
Non-nullable by default
Plain T cannot hold None. Optional values use T?, which is sugar for T | None:
def find_user(id: int) -> str?: if id == 1: return "Alice" return None
def greet(name: str) -> None: print(f"Hello, {name}")
def main() -> None: let found: str? = find_user(42) # greet(found) # ❌ `str?` not assignable to `str`The compiler tells you exactly why:
error[tyc::nullable_use]: cannot pass `str | None` where `str` is required ┌─ src/main.ty:9:11 │9 │ greet(found) │ ^^^^^ check this is not `None` before passing itFlow narrowing
Once you check, the type narrows. Inside the if branch, found is str, not str?:
def main() -> None: let found: str? = find_user(42) if found is not None: greet(found) # ✅ narrowed to str else: greet("stranger")is None, is not None, and isinstance(x, T) all narrow. So does early-return:
def main() -> None: let found: str? = find_user(42) if found is None: return greet(found) # ✅ everything after the guard sees `str`guard for early-return narrowing
A common pattern — bail out on None, then use the value — has dedicated sugar:
def handle(id: int) -> None: guard name = find_user(id) else: print("not found") return greet(name) # `name` narrowed to `str`guard is covered in more depth in guard and Control Flow.
How T? emits
Typhon stores nullability internally as Nullable[T], but emits the standard Python form:
def find_user(id: int) -> str?: ...def find_user(id: int) -> str | None: ...Existing Python tooling (mypy, pyright, IDEs) sees str | None and handles it exactly as you’d expect.
Implicit Any — the convention
Typhon’s long-term intent is to refuse implicit Any outside an unsafe: region — stricter than TypeScript’s noImplicitAny. Today the type system allows Any to flow freely (it’s the top type), so an unconstrained import binds silently:
import some_untyped_lib
def main() -> None: let data = some_untyped_lib.fetch() # binds to Any silentlyThe recommended convention is one of the two patterns below — write them yourself; reviewers will start expecting them, and a future release will enforce them:
# Option A: assert the type with an annotationlet data: dict[str, int] = some_untyped_lib.fetch()
# Option B: wrap in `unsafe` (acknowledges the dynamic boundary)unsafe: let data = some_untyped_lib.fetch()unsafe: is the right tool when you genuinely don’t know the type — e.g. exploring a new dependency. For production code, an annotation or a .dty stub is cleaner. See The Unsafe Boundary for the full story.
Putting it together
A tiny example that uses everything in this page:
import os
def parse_port(raw: str?) -> int?: guard r = raw else: return None if r.isdigit(): return int(r) return None
def main() -> None: let port: int? = parse_port(os.environ.get("PORT")) mut host: str = "localhost"
if port is None: print(f"using default port on {host}") else: print(f"binding {host}:{port}")from __future__ import annotationsimport os
def parse_port(raw: str | None) -> int | None: __typhon_mguard_0 = raw if __typhon_mguard_0 is None: return None r = __typhon_mguard_0 if r.isdigit(): return int(r) return None
def main() -> None: port: int | None = parse_port(os.environ.get("PORT")) host: str = "localhost" if port is None: print(f"using default port on {host}") else: print(f"binding {host}:{port}")Walk-through:
parse_porttakes astr?and returns anint?. Both can beNone.guard r = rawshadowsrawwith the narrowedstrinside the function body.os.environ.get(...)returnsstr | None— Python sees this and the types line up.- The
if port is Nonecheck narrowsporttointin theelsebranch, so the f-string is safe. hostismutbecause… well, in this example it isn’t reassigned, so we could have usedlet. The compiler won’t complain about over-mutability, but readers will.
What you’ve learned
letis for bindings you don’t intend to rebind;mutopts into mutability. Module-level defaults tolet.Tnever holdsNone;T?does, and the checker tracks it.- Flow narrowing lets you use
T?values safely once you’ve checked them —if,is None,isinstance,guard. - No implicit
Any— either annotate or wrap inunsafe:. let/mutare erased at emit time; the Python output is identical to what you’d write by hand.
Where next
- Functions — signatures, defaults, lambdas, generic functions.
- Control Flow —
if,while,for,guard,match. - The Unsafe Boundary — exactly how
unsafe:works under the hood. - Nullable Types —
T?reference and all the narrowing forms.