Skip to content

Nullable Types (T?)

T? is sugar for T | None. Plain T cannot hold None — that is the core of Typhon’s non-nullable-by-default rule. The checker tracks nullability flow-sensitively and narrows T? to T after the usual checks.

Spelling

WantTyphonEmitted
Non-nullable intintint
Nullable intint?int | None
Nullable list of nullable stringslist[str?]?list[str | None] | None
Nullable dict valuedict[str, int?]dict[str, int | None]
Nullable param with default Nonename: str? = Nonename: str | None = None

T? is the preferred spelling. T | None is also accepted silently. Optional[T] from typing is accepted with one advisory warning (tyc::typing_alias_in_annotation, “prefer T? here”); the migrator rewrites it. Internally Typhon represents nullability as Nullable[T], but the emitter always lowers to T | None.

Default values

T? does not auto-default to None. If you want both nullable and defaulted, spell it:

def find(name: str?) -> int?: ... # caller must pass an arg (even None)
def find(name: str? = None) -> int?: ... # caller may omit

Narrowing forms

The checker recognises five narrowing forms:

1. is None / is not None

def f(x: int?) -> int:
if x is None:
return 0
return x # narrowed to int

2. Early-return from is None

def f(x: int?) -> int:
if x is None:
return 0
return x + 1 # narrowed to int by reachability

3. isinstance(x, T)

def f(x: int | str | None) -> str:
if isinstance(x, str):
return x # narrowed to str
if isinstance(x, int):
return str(x) # narrowed to int
return ""

4. guard x = expr else: ...

def f(maybe: int?) -> int:
guard x = maybe else:
return 0
return x # narrowed to int

5. Truthiness (limited)

Truthy / falsy narrowing works on nullable types specifically:

def f(x: str?) -> int:
if x: # narrows x to str (non-empty)
return len(x)
return 0

Width-preserving operations

Some operations preserve nullability through their type:

let xs: list[int]? = get_list()
let ys: list[int]? = xs # still nullable
let s: list[int] = xs or [] # `or` collapses None → []

or with a fallback is a common idiom for “narrow to T”:

let name: str = maybe_name or "anonymous"

How T? emits

def greet(name: str?) -> str: ...

Existing Python type checkers (mypy, pyright, IDEs) handle T | None natively. There is no Typhon-specific type to learn at the interop boundary.

Common cases

dict.get(k) returns V?

let counts: dict[str, int] = {"a": 1}
let n: int? = counts.get("missing") # nullable

re.match returns Match?

import re
let m: re.Match? = re.match(r"\d+", "123")
if m is not None:
print(m.group())

Optional-chaining is not supported

Typhon does not ship the ?. operator. Use guard / if x is not None:

guard u = find_user(id) else:
return None
print(u.name)

Common mistakes

Calling a method on a T? without narrowing

let s: str? = maybe_str()
print(s.upper()) # ❌ tyc::nullable_use

Fix: narrow first (if s is not None:) or use guard.

Returning None from a T function

def find(id: int) -> str:
return None # ❌ None not assignable to str

Fix: change the signature to -> str? or return a real str.

Optional[T] from typing

from typing import Optional
def f(x: Optional[int]) -> None: ... # accepted with a warning — prefer `x: int?`

tyc migrate rewrites this automatically.

Where next

  • Flow Narrowing — every form the checker recognises.
  • guard — the dedicated early-return-with-narrowing form.
  • Result reference — when “missing” is really a typed error.