Type Checking
Beacon provides powerful static type checking for Python code, combining the rigor of Hindley-Milner type inference with the flexibility needed for Python's dynamic features.
Suppressing Type Errors
Type checking errors can be suppressed using inline comments:
x: int = "string" # type: ignore
value: str = 42 # type: ignore[assignment] # Suppress specific error type
See Suppressions for complete documentation on suppression comments.
Type System Philosophy
Beacon's type checker is designed with a core principle: context-aware strictness. It maintains strong type safety for genuinely unsafe operations while being permissive for common, safe Python patterns.
Design Goals
- High Signal-to-Noise Ratio: Report errors that matter, not false positives from valid Python code
- Catch Real Bugs: Focus on type mismatches that lead to runtime errors
- Support Gradual Typing: Work seamlessly with both annotated and unannotated code
- Python-First Semantics: Understand Python idioms rather than forcing ML-style patterns
Union and Optional Types
How Union Types Work
Union types represent values that can be one of several types. Beacon treats union types using subtyping semantics rather than strict structural equality.
# This is valid - None is a member of Optional[int]
def get_value() -> int | None:
return None # No error
# Union members work naturally
x: int | str = 42 # int is a subtype of int | str
y: int | str = "hello" # str is a subtype of int | str
Optional Types
Optional[T] is syntactic sugar for Union[T, None]. Beacon understands that None is a valid value for Optional types without requiring explicit checks:
from typing import Optional
def process(value: Optional[str]) -> None:
# Assigning None to Optional is always valid
result: Optional[str] = None # No error
Type Narrowing
While union types are permissive for assignment, accessing attributes or calling methods requires narrowing:
def process(value: int | None) -> int:
# Error: None doesn't have __add__
return value + 1
def process_safe(value: int | None) -> int:
if value is None:
return 0
# value is narrowed to int here
return value + 1 # OK
Beacon provides several narrowing mechanisms:
- None Checks:
if x is None/if x is not None - isinstance() Guards:
if isinstance(x, int) - Truthiness:
if xnarrows away None and falsy values - Type Guards: User-defined type guard functions
- Match Statements: Pattern matching with exhaustiveness checking
Subtyping vs Unification
Beacon's type checker uses two complementary mechanisms:
Unification (Strict)
Used for non-union types. Requires structural equality:
x: int = 42
y: str = x # Error: cannot unify int with str
Subtyping (Flexible)
Used when union types are involved. Checks semantic compatibility:
x: int = 42
y: int | str = x # OK: int <: int | str
z: int | str | None = None # OK: None <: int | str | None
This hybrid approach provides:
- Strictness where it matters: Direct type mismatches are caught
- Flexibility for unions: Common patterns like Optional work naturally
Type Inference
Beacon infers types even without annotations:
def add(x, y):
return x + y
# Inferred type: (int, int) -> int or (str, str) -> str
# (overloaded based on usage)
numbers = [1, 2, 3]
# Inferred type: list[int]
Value Restriction
Beacon applies the value restriction to prevent unsafe generalization:
empty_list = [] # Type: list[Never] - cannot generalize
# Must provide type hint for empty collections:
numbers: list[int] = [] # Type: list[int]
Special Types
Any
Any is the escape hatch for truly dynamic code. It unifies with all types without errors:
from typing import Any
def dynamic_operation(x: Any) -> Any:
return x.anything() # No type checking
Use Any sparingly - it disables type checking for that value.
Never
Never represents impossible values or code paths that never return:
def unreachable() -> Never:
raise RuntimeError("Never returns")
def example(x: int) -> int:
if x < 0:
unreachable()
return x # Type checker knows we only reach here if x >= 0
Top (⊤)
Top is the supertype of all types. It appears in generic bounds and protocol definitions but is rarely used directly.
Flow-Sensitive Type Narrowing
Beacon tracks type information through control flow:
def process(x: int | str | None) -> int:
if x is None:
return 0
# x: int | str here
if isinstance(x, int):
return x
# x: str here
return len(x) # OK: x is definitely str
This works with:
- If statements
- While loops
- Match statements
- Try-except blocks
- Boolean operators (
and,or)
Exhaustiveness Checking
Match statements and if-elif chains are checked for exhaustiveness:
def handle(x: bool) -> str:
match x:
case True:
return "yes"
case False:
return "no"
# OK: all cases covered
def incomplete(x: int | str) -> str:
if isinstance(x, int):
return "number"
# Warning: str case not handled
Generic Types
Beacon supports generic types with type parameters:
from typing import TypeVar, Generic
T = TypeVar('T')
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
# Type inference works:
int_box = Box(42) # Box[int]
str_box = Box("hello") # Box[str]
Protocols
Beacon supports structural typing through protocols:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def render(obj: Drawable) -> None:
obj.draw() # OK if obj has draw() method
class Circle:
def draw(self) -> None:
print("drawing circle")
render(Circle()) # OK: Circle satisfies Drawable protocol
Common Patterns
Optional Chaining
from typing import Optional
def get_name(user: Optional[dict]) -> Optional[str]:
if user is None:
return None
return user.get("name") # Type checker knows user is dict
Union Type Discrimination
def process(value: int | list[int]) -> int:
if isinstance(value, int):
return value
return sum(value) # value is list[int] here
Type Guard Functions
from typing import TypeGuard
def is_str_list(val: list) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process(items: list[int | str]) -> None:
if is_str_list(items):
# items: list[str] here
print(",".join(items)) # OK
Error Messages
Beacon provides context-aware error messages:
- String/Int Mixing: Suggests explicit conversion
- None Errors: Explains Optional types and None checks
- Union Errors: Shows which union branches failed and why
- Collection Mismatches: Identifies list vs dict vs tuple confusion
Error messages focus on actionable fixes rather than type theory jargon.
Configuration
Type checking strictness can be controlled via beacon.toml:
[analysis]
# Warn when Any is used (default: false)
warn-on-any = true
# Strict mode: disallow implicit Any (default: false)
strict = false
# Report unused variables (default: true)
unused-variables = true
Best Practices
- Use Optional for nullable values:
Optional[T]is clearer thanT | Nonefor function signatures - Narrow before use: Check for None before accessing attributes
- Leverage type guards: Create reusable type narrowing functions
- Avoid Any: Use Union types or Protocol types for flexibility
- Add type hints to empty collections: Help inference with
list[int]()instead of[] - Trust the type checker: If it says a path is unreachable, it probably is
When Type Checking Fails You
Sometimes the type checker can't infer what you know is true. Use these escape hatches:
from typing import cast, Any
# cast: Assert a type without runtime check
value = cast(int, get_dynamic_value())
# Any: Disable type checking
dynamic: Any = get_unknown_type()
# Type ignore comment (use sparingly)
result = complex_operation() # type: ignore
# Assert narrowing
x: int | None = get_value()
assert x is not None
# x: int here (type checker understands assert)
Use these sparingly and document why the type checker needs help.