Skip to content

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:

types.ts
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:

convert_field.py
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:

  1. IsAssignable[T, S] - Returns True if T is assignable to S
  2. IsEquivalent[T, S] - Returns True if T is equivalent to S
  3. Bool[T] - Returns the truth value of T (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:

type_tests.py
from typing import Literal
import typing
# Testing IsAssignable
type CanAssignToInt[T] = typing.IsAssignable[T, int]
# These would type-check correctly:
x: CanAssignToInt[int] = True # int is assignable to int
y: CanAssignToInt[str] = False # str is not assignable to int
# Testing IsEquivalent
type IsOne[T] = typing.IsEquivalent[T, Literal[1]]
a: IsOne[Literal[1]] = True # Equivalent
b: IsOne[Literal[2]] = False # Not equivalent
c: IsOne[int] = False # Not equivalent (int != Literal[1])

I initially made the mistake of trying to use these at runtime:

wrong_usage.py
# 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 T equals S or S equals 1, return T
  • If T equals 1, return S
  • Otherwise, raise a type error

Here’s my first attempt in TypeScript:

broadcast.ts
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:

broadcast_merge.py
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:

  1. First condition: If T equals S OR S equals Literal[1], return T
  2. Second condition: If T equals Literal[1], return S
  3. 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.

custom_error.py
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:

FeaturePython PEP 827TypeScript
SyntaxT if cond else Fcond ? T : F
Condition TypeType Booleans onlyAny type condition
Custom ErrorsRaiseError[msg, ...]never (no message)
ReadabilityMore explicitMore 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:

api_types.py
type APIResponse[T, Auth] = (
T
if typing.IsEquivalent[Auth, Literal["authenticated"]]
else typing.RaiseError[
Literal["APIResponse requires authentication"],
T
]
)
# Usage
type 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:

  1. Type narrowing in generic functions - Making sure generic parameters meet specific requirements
  2. API contract enforcement - Ensuring API calls have the right authentication
  3. Configuration validation - Catching configuration errors at type-check time
config_types.py
type ValidatedConfig[T] = (
T
if typing.IsAssignable[T, BaseConfig]
else typing.RaiseError[
Literal["Config must inherit from BaseConfig"],
T
]
)

Tips from My Experience

  1. Start simple: Don’t nest conditional types too deeply. Extract intermediate types.
refactoring.py
# 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]
  1. Use descriptive error messages: RaiseError is your friend.
error_messages.py
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
]
)
  1. 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:

  1. Python’s IsAssignable[T, S] ≈ TypeScript’s T extends S
  2. Python’s IsEquivalent[T, S] ≈ TypeScript’s T extends S && S extends T (but more direct)
  3. Python’s RaiseError has 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments