Collections
Typhon’s collection types are Python’s, with one stricter rule: element types are mandatory. A function parameter or return type annotated as bare list (without [T]) is rejected by tyc::missing_annotation; on a let / mut binding, an empty literal like let xs = [] infers list[Any] and silently flows into whatever consumes it — annotate as list[T] to keep the type system honest.
list[T]
Mutable, ordered sequences. 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"] # ✅Methods
All Python list methods are available with their normal signatures:
let xs: list[int] = [3, 1, 4, 1, 5, 9, 2, 6]
xs.append(5)xs.extend([1, 1, 5])xs.insert(0, 99)xs.sort()xs.reverse()let n: int = xs.count(1)let i: int = xs.index(5) # raises ValueError if missingxs.remove(1)let popped: int = xs.pop() # raises IndexError on emptylet copy: list[int] = xs.copy()Element access by index is typed T:
let first: int = xs[0] # ✅let bad: int = xs[100] # ✅ at type level; raises IndexError at runtimeIf you want a T? “either there or absent” form, write a helper:
def at[T](xs: list[T], i: int) -> T?: if 0 <= i < len(xs): return xs[i] return Nonedict[K, V]
Mutable mappings, both key and value type required:
let counts: dict[str, int] = {"apples": 3, "pears": 1}let n: int? = counts.get("apples") # `.get` returns V | NoneMethods
let m: dict[str, int] = {"a": 1, "b": 2}
m["c"] = 3let exists: bool = "a" in mlet val: int = m["a"] # typed V; raises KeyError on misslet or_default: int = m.get("z", 0) # second arg sets the default; type stays Vlet popped: int = m.pop("a") # raises KeyError if missinglet keys: list[str] = list(m.keys())let values: list[int] = list(m.values())let items: list[tuple[str, int]] = list(m.items())Default values
dict.get(key, default) returns V (not V?) when a default is provided:
let n: int = counts.get("missing", 0) # ✅ typed int (default `0` fills in)For more complex defaults, defaultdict works:
from collections import defaultdictlet by_letter: defaultdict[str, list[int]] = defaultdict(list)by_letter["a"].append(1)set[T]
Mutable, unordered, unique-element collections:
let seen: set[int] = {1, 2, 3}mut working: set[str] = set()working.add("hello")working.update(["world", "again"])
let exists: bool = 1 in seenlet count: int = len(seen)Set operations
let a: set[int] = {1, 2, 3}let b: set[int] = {2, 3, 4}
let union: set[int] = a | b # {1, 2, 3, 4}let intersect: set[int] = a & b # {2, 3}let diff: set[int] = a - b # {1}let sym_diff: set[int] = a ^ b # {1, 4}
let subset: bool = a <= b # Falselet superset: bool = a >= b # FalseFor frozen sets, use frozenset[T] — same operations, hashable, suitable for dict keys.
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)let person: tuple[str, int, bool] = ("alice", 30, True)Destructuring works as in Python:
let x, y = pointlet r, g, b = rgblet name, age, active = personVariable-length homogeneous tuples
let nums: tuple[float, ...] = (1.0, 2.0, 3.0)tuple[T, ...] is “any length of T”. Used by *args:
def average(*nums: float) -> float: # `nums` has type `tuple[float, ...]` return sum(nums) / len(nums)Single-element tuples
A common Python pitfall:
let t1: tuple[int] = (1,) # ✅ trailing comma — this is a 1-tuplelet t1: tuple[int] = (1) # ❌ this is just the int 1 in parensComprehensions
List, set, and dict comprehensions are unchanged from Python:
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]:
let gen: Iterator[int] = (n * 2 for n in nums)let total: int = sum(n * 2 for n in nums) # Iterator[int] consumed by sumIteration
for x in xs: works on anything iterable; the checker infers the element type:
let names: list[str] = ["a", "b", "c"]for name in names: # name: str print(name)
for i, name in enumerate(names): # i: int, name: str print(f"{i}: {name}")
let pairs: dict[str, int] = {"a": 1, "b": 2}for k, v in pairs.items(): # k: str, v: int print(f"{k}={v}")
for a, b in zip(["a", "b"], [1, 2]): # a: str, b: int print(a, b)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 tyc::nullable_use error.
Mutable vs immutable
The standard library distinction holds:
| Mutable | Immutable |
|---|---|
list[T] | tuple[T, ...], tuple[A, B] |
dict[K, V] | MappingProxyType[K, V] (read-only view) |
set[T] | frozenset[T] |
For deep immutability inside a frozen class, use immutable containers (tuple, frozenset) for the field types.
Read-view covariance (v0.9.0)
Functions that only read from a collection should declare the read-view type rather than the concrete one. The checker covariates the type parameter for the standard read-only protocols, so passing a list[Dog] where a Sequence[Animal] is expected works without any cast:
class Animal: name: strclass Dog(Animal): breed: str
def names(animals: Sequence[Animal]) -> list[str]: return [a.name for a in animals]
let dogs: list[Dog] = [Dog(name="rex", breed="poodle")]names(dogs) # ✅ since v0.9.0| Read-only protocol | Variance | Concrete types that flow in |
|---|---|---|
Sequence[T] | covariant on T | list[T], tuple[T, ...] |
Iterable[T] | covariant on T | list[T], tuple[T, ...], set[T], frozenset[T], generators |
Iterator[T] | covariant on T | generators, iter(...) results |
Collection[T] | covariant on T | list[T], tuple[T, ...], set[T], frozenset[T] |
Container[T] | covariant on T | every collection with __contains__ |
Reversible[T] | covariant on T | list[T], tuple[T, ...] |
Mapping[K, V] | K invariant, V covariant | dict[K, V], MappingProxyType[K, V] |
MutableMapping[K, V] | K invariant, V covariant | dict[K, V] |
Use the concrete list[T] / dict[K, V] only when the function must mutate. For pure consumption, the read-view spelling is more accurate and accepts more callers:
def total(xs: Sequence[float]) -> float: # ✅ accepts list[float], tuple[float, ...] return sum(xs)
def append_zero(xs: list[float]) -> None: # mutates — list[float] required xs.append(0.0)Before v0.9.0 these protocols were treated as invariant on their type parameters, so callers had to widen at the call site (list[Animal](dogs)) or the function had to spell the union type. The v0.9.0 widening is purely additive — every previously-accepted program still type-checks.
What is not covariant
list[Dog] does not flow into list[Animal] (writes through the wide view would let you append(Animal()) to a list[Dog]). dict[str, Dog] does not flow into dict[str, Animal]. The covariance is read-protocol-only.
Common mistakes
Bare collection annotations
let xs: list = [1, 2, 3] # ❌ implicit Any elementlet d: dict = {"a": 1} # ❌ implicit Any keys and valuesFix: spell the type parameters.
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"), or provide a default: let n: int = counts.get("missing", 0).
Heterogeneous list without a union annotation
let mixed: list[int] = [1, "two"] # ❌Fix: let mixed: list[int | str] = [1, "two"] or split into separate typed lists.
Where next
- Nullable Types —
T?, narrowing forms, anddict.get. - Flow Narrowing — exactly what the checker tracks.