A pragmatic compromise for intersection-like semantics in Python with hints.
Project description
Conjunction Types
A pragmatic compromise for intersection-like semantics in Python with hints.
Installation
Available on PyPI:
# Using pip
pip install conjunction-types
# Using uv (recommended)
uv pip install conjunction-types
Requires Python 3.12 or later.
Overview
Conjunction[A | B | C] represents a container holding exactly one value of each type A, B, and C.
Accurate type hints are provided throughout.
from conjunction_types import Conjunction
# Type construction: | inside Conjunction[...] declares member types
UserData = Conjunction[int | str | dict[str, str]]
Credentials = Conjunction[str | dict[str, str]]
# Type operations: subset checking (covariant)
assert int in UserData # type membership
assert Credentials in UserData # Credentials <: UserData
assert UserData not in Credentials # not a subset
# Type combination: & operator merges Conjunction types (type-level, commutative)
Combined = Conjunction[int | str] & Conjunction[float | bool]
assert Combined == Conjunction[int | str | float | bool]
# Value construction: infer types from arguments
user = Conjunction(42, "alice", {"role": "admin"})
profile = Conjunction("bob", {"status": "active"})
# Value composition: & merges instances (right-precedence for overlapping types; warning emitted)
merged = user & profile
assert int in merged # from user
assert str in merged # both had str; profile's wins
assert dict[str, str] in merged # both had dict; profile's wins
# Subscript indexing: extract subset of types
partial: Conjunction[int | str] = merged[int | str]
assert int in partial and str in partial
assert dict not in partial # dict was excluded
# Operations preserve type structure
data1 = Conjunction(1, "x") & Conjunction(2.0)
data2 = Conjunction(1, "y", 3.0)
assert (data1 & data2)[int | float] == Conjunction(1, 3.0)[int | float]
# Type equality: permutation-invariant
assert Conjunction[int | str] == Conjunction[str | int]
assert Conjunction(1, "a") != Conjunction("a", 1) # values differ
Conceptual Foundations
Conjunction is a type-indexed product that enables intersection-like behavior through explicit projection.
Intersection Types (via projection)
In formal type theory, an intersection type A ∧ B is a value that is simultaneously A and B with subtyping A ∧ B <: A and A ∧ B <: B. Python cannot express such multiple-inheritance-of-instance semantics in a way usable to the type checkers.
Conjunction provides projection-based intersection semantics instead:
# Traditional intersection: cannot be spelled in Python typing today
# x: A & B # would be both A and B simultaneously
# Conjunction approach: separate storage, type-safe extraction
c: Conjunction[A | B] = Conjunction(a_instance, b_instance)
# Projection yields true instances of the requested type
x: A = c.to(A) # type: A
y: B = c.to(B) # type: B
# Semantic law: projection is defined iff the type is present
# Conjunction[T1 | ... | Tn].to(T') <: T' ⇔ T' ∈ {T1, ..., Tn}
# Subtyping does not hold at the base types:
issubclass(Conjunction[A | B], A) # False
issubclass(Conjunction[A | B], B) # False
# But subset relations hold among Conjunction types:
AB = Conjunction[int | str]
ABC = Conjunction[int | str | float]
assert issubclass(AB, ABC) # AB <: ABC
This mirrors intersection semantics at use sites as long as contained objects are state-disjoint (no shared mutable state). Broadcasting writes across all members (by intercepting __setattr__) is theoretically possible but intentionally omitted due to MRO complexity and low practical gain. In fact, this probably indicates that you really want a new type that groups the shared state together.
Product Types (true at runtime)
Conjunction[A | B | C] is a real product: it stores one value per member type, behaving like a type-indexed map {type → instance}.
# Conceptually: Conjunction[A | B | C] ≅ (a: A, b: B, c: C)
# Type-indexed vs position-indexed
pair = (42, "hello") # positional
bag = Conjunction(42, "hello") # type-indexed: bag.to(int) == 42
# Type uniqueness: cannot represent (int, int)
# Distinct parametric types *are* allowed
c = Conjunction({"a": 1}, {"b": "x"}) # dict[str,int] + dict[str,str]
xi: dict[str, int] = c.to(dict[str, int])
xs: dict[str, str] = c.to(dict[str, str])
Algebraic Properties
There are two levels to keep straight:
- Type level: combining
Conjunction[...]types.- Value level: merging runtime instances with
&.
| Property | Type level | Value level | Notes |
|---|---|---|---|
| Associative | ✓ | ✓ | (A & B) & C == A & (B & C) |
| Commutative | ✓ | × | Type-level & is set-like; value-level & is right-precedence for overlapping types |
| Idempotent | ✓ | ✓ | Duplicates are flattened; merging same type keeps right-hand value |
| Identity | ✓ | ✓ | Conjunction[] is identity for & |
# Associativity
c1 = Conjunction(1, "a")
c2 = Conjunction(2.0, True)
c3 = Conjunction([], {})
assert (c1 & c2) & c3 == c1 & (c2 & c3)
# Non-commutativity at value level (right side wins on conflict)
x = Conjunction(42, "first")
y = Conjunction(99, "second")
z = x & y
assert z.to(int) == 99
assert z.to(str) == "second"
Operator Semantics (the | and & "trick")
- Inside
Conjunction[...], the|operator lists member types. Although|means union in Python, here it’s used to declare the conjunctive product’s index set. - At the type level,
&combines twoConjunction[...]types by taking the union of their member sets. This is commutative and associative. - At the value level,
&merges two instances. This is associative but not commutative; on overlapping types, the right-hand instance’s value wins. [](subscription) selects a subset at the type level and returns a newConjunction[...]of that subset./removes types (set difference) at the value level.
# Type level
AB = Conjunction[int | str]
BC = Conjunction[str | bool]
ABBC = AB & BC
assert ABBC == Conjunction[int | str | bool]
# Value level
v = Conjunction(1, "x") & Conjunction("y")
assert v.to(str) == "y" # right-precedence on str
# Subselection and difference
sub: Conjunction[int] = v[int]
v2 = v / str
assert str not in v2
API Reference (selected)
Type Construction
UserID = Conjunction[int]
UserProfile = Conjunction[int | str | dict[str, str]]
# Subset checking (covariant)
assert int in UserProfile
assert UserID in UserProfile # Conjunction[int] <: Conjunction[int|str|dict]
# Type equivalence (permutation invariant)
assert Conjunction[int | str] == Conjunction[str | int]
# Generic types are distinguished
IntMap = Conjunction[dict[str, int]]
StrMap = Conjunction[dict[str, str]]
assert IntMap != StrMap
Instance Construction & Composition
c = Conjunction(42, "hello", 3.14)
assert {int, str, float} <= set(c)
# Compose existing conjunctions
c1 = Conjunction(42, "alice")
c2 = Conjunction({"role": "admin"})
merged = c1 & c2 # Conjunction[int | str | dict]
# Right-precedence on overlaps
x = Conjunction(1, "first")
y = Conjunction(2, "second")
z = x & y
assert z.to(int) == 2 and z.to(str) == "second"
Extraction & Slicing
c = Conjunction(42, "hello", 3.14)
# Single value extraction (type-safe)
value: int = c.to(int)
# Partial extraction (returns Conjunction)
sub: Conjunction[int | str] = c[int | str]
assert float not in sub and sub.to(int) == 42
# Membership testing (types or Conjunction types)
assert int in c
assert (int | str) in c
assert not ((int | bool) in c)
Set-Like Operations
c = Conjunction(42, "hello", 3.14, True)
# Remove types via /
c2 = c / str # Remove str
c3 = c / (str | bool) # Remove str and bool
assert int in c2 and float in c2 and str not in c2
Properties
c = Conjunction(42, "hello", 3.14)
# Immutable
try:
c.field = "value"
except TypeError:
pass
# Hashable
s = {c}
d = {c: "metadata"}
# Iterate types / values
for typ in c:
print(typ)
for typ, wrapped in c.items():
assert wrapped.to(typ) is not None
NDJSON Serialization (Optional Extension)
The optional ndjson extension provides utilities for serializing and deserializing Conjunction instances to NDJSON (newline-delimited JSON) format. This is useful for incremental logging, data persistence, and resumption workflows.
# Install with ndjson support (no extra dependencies required)
pip install conjunction-types[ndjson]
Basic Usage
from conjunction_types import Conjunction
from conjunction_types.ndjson import NDJSONFile
# Create and write Conjunctions to file
file = NDJSONFile("data.ndjson")
file.append(Conjunction(42, "alice", {"role": "admin"}))
file.append(Conjunction(100, "bob", {"role": "user"}))
# Read back
for conj in file.read():
print(conj.to(int), conj.to(str)) # 42 alice, 100 bob
Minting Types for Serialization
Python's type erasure problem: At runtime, Conjunction([1,2,3]) stores the key as list, not list[int], because generic type information is erased. To preserve generic types in serialization or to create multiple "slots" for the same base type, use mint():
from conjunction_types import Conjunction, mint
# Mint creates a distinct type constructor with a stable name
IntList = mint("IntList", list[int])
StrList = mint("StrList", list[str])
# Each minted constructor creates tagged values
c = Conjunction(IntList([1, 2, 3]), StrList(["a", "b"]), [4, 5, 6])
# Minted types are distinct from each other and from the base type
assert c.to(IntList) == [1, 2, 3]
assert c.to(StrList) == ["a", "b"]
assert c.to(list) == [4, 5, 6] # Unminted list
# When serialized, mint names are preserved
from conjunction_types.ndjson import NDJSONFile
file = NDJSONFile("data.ndjson")
file.append(c)
# Deserializes correctly with type distinctions intact
for restored in file.read():
assert restored.to(IntList) == [1, 2, 3]
assert restored.to(StrList) == ["a", "b"]
Custom Type Serialization
For non-JSON-serializable types, register custom serializers:
from conjunction_types import Conjunction
from conjunction_types.ndjson import mint, NDJSONFile
from pathlib import Path
# Register custom serialization logic
PathType = mint(
"PathType",
Path,
serializer=str, # Path -> str
deserializer=Path # str -> Path
)
# Use in Conjunctions
file = NDJSONFile("paths.ndjson")
file.append(Conjunction(PathType(Path("/tmp")), "config"))
# Deserializes correctly
for conj in file.read():
path: Path = conj.to(PathType)
assert isinstance(path, Path)
Type Registry for Global Serialization
For types used across multiple files, use a shared TypeRegistry:
from conjunction_types.ndjson import TypeRegistry, NDJSONFile
from pathlib import Path
# Create a registry with custom serialization logic
registry = TypeRegistry()
registry.register(
Path,
serializer=str,
deserializer=Path,
)
# Use the same registry for all files
file1 = NDJSONFile("data1.ndjson", registry=registry)
file2 = NDJSONFile("data2.ndjson", registry=registry)
Advanced (Theory): Monadic and Conjunction-Aware Abstractions
Status: not implemented; this section documents a unified design direction combining Monads, a
@conjdecorator, and Ellipsis-based late casting.
Conjunction already acts as a context that can hold auxiliary state alongside base types. This naturally suggests extending the concept into monadic lifting, where each base type is paired with a state carrier, and functions can automatically interact with both ordinary and monadic conjunctions through a decorator.
1. Concept: Monadic Conjunctions
Define a type constructor MyMonad[T] : Conjunction[T | MyMonadState[T]], where MyMonadState[T] tracks contextual information (e.g., logging, tracing, dependency injection state). The MyMonad class can override .to(...) to store or modify the associated MyMonadState before projecting the requested type.
class MyMonad[T](Conjunction[T | MyMonadState[T]]):
def to[U](self, typ: type[U]) -> U:
state = self.to(MyMonadState[T])
state.before_access(typ) # custom hook
value = super().to(typ)
state.after_access(typ, value)
return value
Here, .to() becomes the natural effect boundary — where side effects are realized, logs are updated, or transactional state transitions occur.
2. The @conj Decorator: Conjunction-Aware Function Calls
The @conj decorator makes functions automatically conjunction-aware, allowing them to receive a Conjunction[...] or a derived monadic variant (like MyMonad[...]) in place of individual parameters.
Semantics
When a decorated function is called:
- The decorator inspects the function’s annotations.
- If the argument is a
Conjunction[...], it projects each annotated parameter type using.to(T). - The function is invoked with those projected values.
- If a parameter type is missing, a
TypeErroris raised. - If the arguments are plain values, the function executes normally.
This allows seamless invocation of ordinary functions with monadic or conjunction-typed inputs.
@conj
def render(user: str, flags: dict[str, bool]) -> str:
return f"User {user}, Flags: {flags}"
# Using a monadic conjunction that logs state access
mon = MyMonad("alice", {"dark": True}, MyMonadState())
result = render(mon) # decorator projects user/flags via monadic .to()
The function’s implementation remains unaware of whether the inputs are pure conjunctions or stateful monads — the @conj decorator and .to() handle the behavior injection transparently.
3. Late Casting and Ellipsis Semantics
The decorator could support late casting by interpreting Conjunction[..., A] as “at least A, possibly more.” This allows writing flexible function signatures that can accept conjunctions containing additional context or state.
@conj
def process(data: Conjunction[..., Payload]) -> None:
payload = data.to(Payload)
log_state = data.to(MyMonadState[Payload]) # optional side data
...
Implementing Ellipsis-aware type construction would let conjunctions express open type sets, e.g.:
# Open conjunction: any conjunction containing str
Conjunction[..., str]
This allows decorators and higher-order functions to express minimum requirements rather than exact conjunction signatures.
4. Unified Behavior Model
The unified model ties together monadic lifting, decorator-based function adaptation, and ellipsis-based polymorphism:
| Mechanism | Role | Effect |
|---|---|---|
.to(T) |
Defines monadic effect boundary | Intercepts access and applies state logic |
@conj |
Enables automatic projection at call sites | Makes ordinary functions conjunction-aware |
... (Ellipsis) |
Declares open conjunctions | Enables partial type matching and late casting |
Together these enable a fully general pattern: composable conjunction-based effects.
@conj
def compute(x: int, y: float) -> float:
return x + y
mon = MyMonad(3, 4.5, MyMonadState())
result = compute(mon) # automatic extraction + side-effect logging
5. Design Notes
- The monadic layer extends existing semantics —
.to()remains the stable API boundary. - Effects and state propagation are explicit but orthogonal to the value semantics of
Conjunction. - The decorator and Ellipsis features can evolve independently; both rely on existing type-indexed lookup.
- A uniform
MonadProtocol(definingmap/flat_map) could generalize this design across implementations.
This architecture unifies contextual state, intersection-like structure, and functional effect semantics within one coherent framework.
Type Relations Summary
| Concept | Relation to Conjunction |
|---|---|
| Intersection | Imitated under .to(T) projection; not a true structural subtype of each member |
| Product | True at runtime (type-indexed Cartesian product over unique keys) |
| Coproduct/Sum | Under .to(T), this forms a simple sum type (branch for each T). |
Union (A| B) |
Syntax reused inside Conjunction[...] to list member types (index set) |
| Tuples / Named tuples | Position-indexed products; Conjunction is type-indexed and cannot duplicate a type |
Requirements
- Python 3.12+
- Zero runtime dependencies
- Works with static type checkers (Pyright, Pylance, mypy)
License
MIT License. See LICENSE for details.
Links
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file conjunction_types-1.0.1.tar.gz.
File metadata
- Download URL: conjunction_types-1.0.1.tar.gz
- Upload date:
- Size: 34.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.7.20
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fd3b9de850db2f8db418cd0e582d9c79bc251cb36925f0d1c5b999d299179070
|
|
| MD5 |
dc7c0bb9f02e7680524b5fb93ad9e2d0
|
|
| BLAKE2b-256 |
63e81ed561978b20b3159c2ea2f9f0ef5d68b4d3d22fe9ae1048344730f6dfe2
|
File details
Details for the file conjunction_types-1.0.1-py3-none-any.whl.
File metadata
- Download URL: conjunction_types-1.0.1-py3-none-any.whl
- Upload date:
- Size: 23.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.7.20
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e3850264d11cc2d5352b9bbc109fe885fc5eea9946c35d1a48c1f4772d599bda
|
|
| MD5 |
3ca3e715be0dff849bda48c951823abc
|
|
| BLAKE2b-256 |
368a84d27b55f1a34370661c468c50990a477d22795c968a9c7fe5ebcb72da7b
|