Conditional Types in Python PEP 827: A Practical Guide
I recently ran into a situation where I needed to create types that changed based on conditions. Coming from TypeScript, I was familiar with conditional types, but I needed to understand how Python’s PEP 827 handles this. Here’s what I discovered.
The Problem
I was building a type-safe data transformation library. I needed types that could behave differently depending on what they received. For example, if a type was assignable to Link, I wanted to adjust it one way; otherwise, I wanted to keep it as-is.
In TypeScript, I would write:
type ConvertField<T> = T extends Link ? AdjustLink<PropsOnly<PointerArg<T>>, T> : PointerArg<T>;But Python’s type system works differently, and I needed to understand PEP 827’s approach.
Python’s Conditional Type Syntax
PEP 827 introduces conditional types with a syntax that feels more Pythonic:
type ConvertField[T] = ( AdjustLink[PropsOnly[PointerArg[T]], T] if typing.IsAssignable[T, Link] else PointerArg[T])The syntax is: type true_typ if bool_typ else false_typ
At first, I found this a bit verbose compared to TypeScript’s ternary syntax. But there’s a key difference: Python uses special “Type Booleans” for the condition, not runtime boolean expressions.
Type Booleans: The Condition Engines
PEP 827 defines three Type Boolean functions:
IsAssignable[T, S]- ReturnsTrueifTis assignable toSIsEquivalent[T, S]- ReturnsTrueifTis equivalent toSBool[T]- Returns the truth value ofT(for boolean types)
These aren’t runtime functions. They’re type-level computations that the type checker evaluates during type checking.
Let me show you how I experimented with these:
from typing import Literalimport typing
# Testing IsAssignabletype CanAssignToInt[T] = typing.IsAssignable[T, int]
# These would type-check correctly:x: CanAssignToInt[int] = True # int is assignable to inty: CanAssignToInt[str] = False # str is not assignable to int
# Testing IsEquivalenttype IsOne[T] = typing.IsEquivalent[T, Literal[1]]
a: IsOne[Literal[1]] = True # Equivalentb: IsOne[Literal[2]] = False # Not equivalentc: IsOne[int] = False # Not equivalent (int != Literal[1])I initially made the mistake of trying to use these at runtime:
# WRONG: These are type-level only!if typing.IsAssignable[str, int]: print("This won't work!")This doesn’t work because IsAssignable is a type-level construct, not a runtime function.
A Practical Example: Broadcast Merging
One use case that really helped me understand the power of conditional types was implementing a broadcast merge type. Here’s the problem:
When merging two values for broadcasting:
- If
TequalsSorSequals1, returnT - If
Tequals1, returnS - Otherwise, raise a type error
Here’s my first attempt in TypeScript:
type MergeOne<T, S> = T extends S ? T : S extends T ? S : never;But this doesn’t capture all the cases correctly. The actual logic is more nuanced.
In Python with PEP 827:
type MergeOne[T, S] = ( T if typing.IsEquivalent[T, S] or typing.IsEquivalent[S, Literal[1]] else S if typing.IsEquivalent[T, Literal[1]] else typing.RaiseError[Literal["Broadcast mismatch"], T, S])This is clearer! Let me break down what’s happening:
- First condition: If
TequalsSORSequalsLiteral[1], returnT - Second condition: If
TequalsLiteral[1], returnS - Fallback: If neither condition matches, raise a custom compile-time error
The RaiseError Advantage
Here’s where Python’s approach shines: RaiseError. TypeScript only has never for impossible types. Python gives you a way to provide custom compile-time error messages.
type SafeDivide[T, S] = ( float if typing.IsAssignable[T, (int, float)] and typing.IsAssignable[S, (int, float)] else typing.RaiseError[ Literal["SafeDivide only accepts numeric types"], T, S ])When someone tries to use SafeDivide[str, int], they get a clear error message: “SafeDivide only accepts numeric types” along with the problematic types.
In TypeScript, you’d get a less helpful “Type ‘string’ is not assignable to type ‘never’” message.
Comparison: Python vs TypeScript
After working with both, here’s my comparison:
| Feature | Python PEP 827 | TypeScript |
|---|---|---|
| Syntax | T if cond else F | cond ? T : F |
| Condition Type | Type Booleans only | Any type condition |
| Custom Errors | RaiseError[msg, ...] | never (no message) |
| Readability | More explicit | More concise |
I find Python’s syntax more explicit about what’s happening. The if typing.IsAssignable[T, S] reads like English. TypeScript’s T extends S ? ... : ... is more concise but requires understanding the extends keyword in type contexts.
The Complexity Trade-off
Reading Reddit discussions, I found this comment with 100 upvotes:
“This feels like the most complicated solution I can imagine”
And honestly? I felt the same way at first. But after using conditional types in real projects, I appreciate the explicitness.
Let me show you a more complex example that convinced me:
type APIResponse[T, Auth] = ( T if typing.IsEquivalent[Auth, Literal["authenticated"]] else typing.RaiseError[ Literal["APIResponse requires authentication"], T ])
# Usagetype UserResponse = APIResponse[User, Literal["authenticated"]] # Works!type PublicResponse = APIResponse[User, Literal["anonymous"]] # Compile error!The error message tells you exactly what went wrong. This saves debugging time.
How I Actually Use This
In my current project, I use conditional types for:
- Type narrowing in generic functions - Making sure generic parameters meet specific requirements
- API contract enforcement - Ensuring API calls have the right authentication
- Configuration validation - Catching configuration errors at type-check time
type ValidatedConfig[T] = ( T if typing.IsAssignable[T, BaseConfig] else typing.RaiseError[ Literal["Config must inherit from BaseConfig"], T ])Tips from My Experience
- Start simple: Don’t nest conditional types too deeply. Extract intermediate types.
# Instead of deep nesting:type Complex[T] = A if cond1 else (B if cond2 else (C if cond3 else D))
# Extract intermediate types:type InnerType[T] = B if cond2 else (C if cond3 else D)type Complex[T] = A if cond1 else InnerType[T]- Use descriptive error messages:
RaiseErroris your friend.
type MustBePositive[T] = ( T if typing.IsEquivalent[T, Literal[1]] or typing.IsAssignable[T, Literal[2]] else typing.RaiseError[ Literal["Type must be positive integer literal"], T ])- Combine with other PEP 827 features: Conditional types work well with
TypeOf,Literal, and other primitives.
What TypeScript Developers Should Know
If you’re coming from TypeScript:
- Python’s
IsAssignable[T, S]≈ TypeScript’sT extends S - Python’s
IsEquivalent[T, S]≈ TypeScript’sT extends S && S extends T(but more direct) - Python’s
RaiseErrorhas no direct TypeScript equivalent
Final Thoughts
Conditional types in PEP 827 feel verbose at first, especially if you’re used to TypeScript’s concise syntax. But the explicitness pays off in error messages and readability.
The ability to provide custom compile-time errors with RaiseError is genuinely useful. It turns type errors from cryptic messages into actionable feedback.
I’m still exploring the full capabilities of PEP 827. The learning curve is real, but the type safety and developer experience improvements make it worthwhile.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 PEP 827 – Type Manipulation Primitives
- 👨💻 TypeScript Conditional Types
- 👨💻 Reddit Discussion on PEP 827
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments