Getting Started
This guide will walk you through the core concepts of optype and show you how to use it effectively in your projects.
The Problem with Generic Type Variables
Let's say you're writing a twice(x) function that evaluates 2 * x. Implementing it is trivial:
But what about the type annotations? At first glance, you might think:
However, this has several problems:
- Type safety: Calling
twice(None)will raise, but type-checkers will accept it - Type transformation:
twice(True) == 2changes the type frombooltoint - Type transformation:
twice((1, 2)) == (1, 2, 1, 2)changes from a 2-tuple to a 4-tuple - Limited to known types: It doesn't account for custom types with
__rmul__methods
The optype Solution
optype provides protocols for special methods. For multiplication, we can use CanRMul[T, R]:
Tis the type of the left operand (in2 * x, this isLiteral[2])Ris the return type of__rmul__
Now the type checker correctly understands:
twice(2) # -> int
twice(3.14) # -> float
twice('I') # -> str (because 'I' * 2 == 'II')
twice(True) # -> int (because 2 * True == 2)
twice((42, True)) # -> tuple[int, bool, int, bool]
Working with Custom Types
optype protocols work seamlessly with custom types:
from typing import Literal
type Two = Literal[2]
class MyNumber:
def __init__(self, value: int):
self.value = value
def __rmul__(self, other: Two) -> str:
return f"{other} * {self.value}"
def twice[R](x: op.CanRMul[Two, R]) -> R:
return 2 * x
result = twice(MyNumber(42)) # -> str
print(result) # "2 * 42"
Runtime Checking with Protocols
Because optype.Can* protocols are runtime-checkable, you can use isinstance() to handle different types at runtime.
For example, what about types that implement __mul__ but not __rmul__? We can return x * 2 as a fallback (assuming commutativity):
import optype as op
from typing import Literal, TypeAlias, TypeVar
R = TypeVar("R")
Two: TypeAlias = Literal[2]
RMul2: TypeAlias = op.CanRMul[Two, R]
Mul2: TypeAlias = op.CanMul[Two, R]
CMul2: TypeAlias = Mul2[R] | RMul2[R]
def twice2(x: CMul2[R]) -> R:
if isinstance(x, op.CanRMul):
return 2 * x
else:
return x * 2
This allows you to write flexible functions that adapt to the capabilities of their arguments.
The Five Flavors of optype
optype provides five categories of types:
1. Just[T] - Exact Type Matching
The invariant Just[T] type accepts only instances of T itself,
rejecting strict subtypes.
def assert_int(x: op.Just[int]) -> int:
assert type(x) is int
return x
assert_int(42) # ✓ OK
assert_int(False) # ✗ Error: bool is a strict subtype of int
Use cases:
- Reject
boolwhen you only wantint - Annotate sentinel objects:
_DEFAULT: op.JustObject = object() - Avoid unwanted type promotions
Important: Use Just[T] only for inputs, never outputs
Just[T] should only be used in input positions (function parameters, constructor arguments, etc.).
```python
# ✓ Correct: Just in input position
def process(x: op.Just[int]) -> int:
return x * 2
# ✗ Wrong: Just in return position
def get_value() -> op.Just[int]: # Don't do this!
return 42
```
2. Can* - What Can Be Done
Protocols describing what operations are can be used.
Each Can* protocol implements a single special "dunder" method.
_: op.CanAbs[int] = 42 # abs(42) -> int
_: CanAdd[str, str] = "hi" # "hi" + "hi" -> str
_: CanGetitem[int, int] = [1] # [1][0] -> int
3. Has* - What Attributes Exist
Protocols for special attributes.
def get_name(obj: op.HasName) -> str:
return obj.__name__
get_name(str) # ✔️
get_name(lambda: None) # ✔️
get_name(None) # ❌
4. Does* - Operator Types
Types for operators themselves (not operands).
5. do_* - Typed Operator Implementations
Correctly-typed operator implementations.
Common Patterns
Accepting Multiple Operations
from typing import Protocol
class CanAddSub(op.CanAdd[int, float], op.CanSub[int, float], Protocol): ...
def process(x: CanAddSub) -> float:
"""Accept types that support both addition and subtraction."""
return (x + 1) - 1
Combining Protocols
Use intersection types to require multiple capabilities:
from typing import Protocol
import optype as op
class CanAddAndMulIntFloat(op.CanAdd[int, float], op.CanMul[int, float], Protocol):
pass
def process(x: CanAddAndMulIntFloat) -> float:
return (x + 1) * 2
Some type checkers may support the & operator for more concise intersections.
Union Types
Use union types (|) for alternative capabilities:
import optype as op
def to_number(x: op.CanInt | op.CanFloat) -> int | float:
if isinstance(x, CanInt):
return int(x)
return float(x)
Generic Functions
Use type parameters for flexible generic functions:
Generic Container Operations
def first_item[T](container: op.CanSequence[int, T]) -> T | None:
"""Get first item from any indexable container."""
if len(container) == 0:
return None
return container[0]
first_item([1, 2, 3]) # -> int | None
Working with NumPy
If you have NumPy installed, optype.numpy provides extensive typing support:
import numpy as np
import optype.numpy as onp
def normalize[T: np.inexact](arr: onp.Array2D[T]) -> onp.Array2D[T]:
"""Normalize a 2D array of real or complex numbers."""
return arr / np.linalg.norm(arr)
See the NumPy reference for complete documentation.
Next Steps
- Explore the Core Types Reference for detailed API documentation
- Check out the Standard Library Modules for more specialized protocols
- Learn about NumPy typing for array operations