TypedDict, Literal, and NewType: Precise Types for Real-World Shapes
Type untyped webhook payloads with TypedDict. Constrain status strings with Literal. Brand order IDs as NewType. The type checker catches bugs you'd ship.
You mentioned Protocol yesterday — structural typing, quack typing. Today we're doing something different?
Today we're getting specific. Protocol asks "what shape does this object have?" TypedDict, Literal, and NewType ask "what exact contract does this payload or value follow?" Yesterday you designed duck types. Today you're writing legal contracts.
That sounds more restrictive than Protocol.
It is. And restriction is what you need when you're handling data from outside your code — webhooks, API responses, configuration files. You don't want to ask "does this look like a payment?" You want to say "this MUST be a PayPal webhook with exactly these fields, this status MUST be one of four values, and this order ID is not just any string — it's an OrderId."
So TypedDict is for dicts? I've been getting dict[str, Any] back from webhook handlers for months and the type checker just... gives up. It can't tell me what's in there.
Exactly. dict[str, Any] is a surrender. You're saying "it's a dict with string keys and values of literally any type." That's why the type checker can't help you catch bugs. Let me show you the shape of a PayPal webhook payload. This is real.
from typing import TypedDict, Literal
class WebhookPayload(TypedDict):
order_id: str
status: Literal['pending', 'paid', 'cancelled', 'refunded']
amount: float
currency: str
def process_webhook(payload: WebhookPayload) -> None:
print(f"Order {payload['order_id']} is {payload['status']}")
# If you try payload['invalid_key'], type checker errors
# If you try to assign payload['status'] = 'maybe', type checker errors
Now the type checker knows exactly what keys the dict has, what type each value is, and what the allowed values are for status. One webhook handler, one line of defense against typos and malformed data.
But dict[str, Any] is also a dict. Why do I need a separate TypedDict?
Because dict[str, Any] is a type annotation for dicts in general. TypedDict is a type annotation for a specific dict shape. Let me show the difference at runtime:
from typing import TypedDict
class WebhookPayload(TypedDict):
order_id: str
status: str
# At runtime, TypedDict returns a dict class
print(type(WebhookPayload)) # <class 'type'>
print(WebhookPayload.__annotations__) # {'order_id': <class 'str'>, 'status': <class 'str'>}
# You can use it as a type hint
payload: WebhookPayload = {"order_id": "ORD-001", "status": "paid"}
# But you cannot instantiate it like a dataclass
# WebhookPayload(order_id="ORD-001", status="paid") # TypeError
# It's just a dict, with type information attached
print(isinstance(payload, dict)) # True
print(payload["order_id"]) # "ORD-001"
TypedDict is only for type checkers. At runtime it's just a dict. That's why it's lightweight and why you use it for data that comes from outside — APIs, webhooks, config files.
So TypedDict is a type hint pretending to be a class?
TypedDict is a type hint that looks like a class. Very good description. Now: what happens when you get a webhook payload from PayPal and the status field is 'pending'? Or 'paid'? Or 'maybe_paid_if_you_squint'?
If it's 'maybe_paid_if_you_squint', that's not a real PayPal status. Your code should crash or reject it. But how does TypedDict help? TypedDict doesn't enforce the status at runtime.
TypedDict is a contract — the type checker reads it and validates that your code is only using the status in ways that are safe for a Literal. But you're right: it doesn't enforce at runtime. That's why you need the second piece. Literal.
from typing import Literal
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
# OrderStatus is a type hint, not a class.
# At runtime, OrderStatus is just a variable holding a special type object.
status: OrderStatus = 'pending' # OK
status: OrderStatus = 'maybe' # Type checker error
Literal says "this value MUST be one of these exact strings (or ints, or any literal value)." The type checker validates it. At runtime, Literal does nothing — it's just the value.
So if Literal doesn't enforce at runtime, what stops a webhook handler from receiving status='invalid'?
Nothing, unless you explicitly validate it. Type checkers only help you write correct code assuming the input is correct. They don't guard against bad input. For that, you write a validator:
from typing import TypedDict, Literal
class WebhookPayload(TypedDict):
order_id: str
status: str # Untyped at ingress
amount: float
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
def process_webhook(raw_payload: dict) -> None:
"""Accept a raw dict, validate it, then process as TypedDict."""
# Validate the status before you use it
if raw_payload['status'] not in ('pending', 'paid', 'cancelled', 'refunded'):
raise ValueError(f"Invalid status: {raw_payload['status']}")
# Now it's safe to treat as TypedDict
payload: WebhookPayload = raw_payload
# Type checker is happy; status is guaranteed one of the four values
So Literal is about type safety, not runtime safety. Runtime safety is your job — validate input, then the type checker knows you're safe.
That's the pattern. Now here's the third piece. OrderId. Right now you have:
def refund_order(order_id: str) -> None:
# Refund the order
pass
def send_email(customer_email: str) -> None:
# Send an email
pass
# Bug waiting to happen:
refund_order(send_email) # Oops, I passed the function
refund_order('alice@example.com') # Oops, I passed an email instead of order ID
Both order_id and customer_email are strings. The type checker sees no problem. But semantically, they're different things. You don't want to pass an email where an order ID is expected. You need NewType.
NewType creates a new type?
NewType creates a brand for a type. It's zero-cost — at runtime, NewType("OrderId", str) is just str. But at the type-checking level, OrderId is a distinct type from str.
from typing import NewType
OrderId = NewType('OrderId', str)
CustomerId = NewType('CustomerId', str)
def refund_order(order_id: OrderId) -> None:
print(f"Refunding {order_id}")
def send_email(customer_email: str) -> None:
pass
# Type checker error: send_email() takes str, not OrderId
refund_order(send_email) # Error
# Type checker error: refund_order() takes OrderId, not str
refund_order('alice@example.com') # Error
# Type checker error: can't pass CustomerId where OrderId is expected
customer_id: CustomerId = CustomerId('CUST-001')
refund_order(customer_id) # Error
# OK
order_id: OrderId = OrderId('ORD-001')
refund_order(order_id) # OK
At runtime, OrderId('ORD-001') just returns the string 'ORD-001'. But the type checker sees them as different.
So NewType is a type-checker-only feature? Like Literal?
Like Literal. All three are type-checker features. Type checkers don't enforce at runtime. But they catch bugs in development. Here's a real scenario: your webhook receives a payload. You extract the status and order ID. Two seconds later, you're calling refund_order(status) instead of refund_order(order_id) because you mixed up variable names. Type checkers catch this.
Okay. So the pattern for handling external data is: use TypedDict for the overall shape, Literal for constrained values, NewType for branded strings that shouldn't be confused with other strings.
That's exactly the pattern. All three together look like this:
from typing import TypedDict, Literal, NewType
OrderId = NewType('OrderId', str)
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
class WebhookPayload(TypedDict):
order_id: OrderId
status: OrderStatus
amount: float
def process_webhook(payload: WebhookPayload) -> None:
# Type checker knows order_id is OrderId, not just str
# Type checker knows status is one of four values
# Type checker knows amount is float
refund_if_pending(payload['order_id'], payload['status'])
def refund_if_pending(order_id: OrderId, status: OrderStatus) -> None:
if status == 'pending':
# refund_order takes OrderId, so you can only pass OrderId
refund_order(order_id)
def refund_order(order_id: OrderId) -> None:
print(f"Refunding {order_id}")
One line: if you ever try to pass a plain string where OrderId is required, the type checker stops you.
But creating a NewType feels verbose. Is it worth it just to catch one mistake?
Imagine your order pipeline has 47 functions that take order IDs. Imagine one developer passes a customer ID to refund_order(). No crash at runtime — it's just a string. But the wrong customer's refund goes through. The mistake cost you the customer, a support ticket, and a refund. NewType costs you two lines of code and prevents that bug class entirely.
So NewType is documentation and insurance rolled into one.
That's exactly what it is. Documentation that the type checker enforces. Now: you also need to know one edge case. When you create an OrderId, you can't do it with a plain string — you have to call the NewType function explicitly:
# Wrong:
order_id: OrderId = 'ORD-001' # Type checker error
# Right:
order_id: OrderId = OrderId('ORD-001')
# At runtime, both are just 'ORD-001', but the type checker tracks which is which
print(type(order_id)) # <class 'str'>
print(isinstance(order_id, str)) # True
NewType exists purely for the type checker. It doesn't create a new class or change the runtime type. It's a phantom brand.
So if I get an order_id from a JSON payload, I can't just assign it to an OrderId variable. I have to wrap it in OrderId()?
You have to cast it. And casting is an admission that you're doing something the type checker can't verify automatically. Here's the real pattern:
import json
from typing import TypedDict, Literal, NewType, cast
OrderId = NewType('OrderId', str)
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
class WebhookPayload(TypedDict):
order_id: str # From JSON, we don't know the type yet
status: str
amount: float
def process_webhook(raw_json: str) -> None:
raw_payload = json.loads(raw_json)
# Validate that the status is one of the allowed values
if raw_payload['status'] not in ('pending', 'paid', 'cancelled', 'refunded'):
raise ValueError(f"Invalid status: {raw_payload['status']}")
# Now we can safely cast
order_id = cast(OrderId, raw_payload['order_id'])
status = cast(OrderStatus, raw_payload['status'])
amount = raw_payload['amount']
payload: WebhookPayload = {
'order_id': order_id,
'status': status,
'amount': amount,
}
refund_if_pending(order_id, status)
cast() looks like it's cheating. You're telling the type checker "trust me, this is an OrderId" even though you didn't enforce it.
You're not enforcing the type — you're enforcing the value at runtime. The type checker can't check that the JSON string really is an order ID (it's just a string). But you did validate that the status is one of four values. So you use cast to bridge the gap: "I validated this at runtime, type checker, you can trust me."
So cast is for the boundary between untyped data (JSON, webhooks) and typed code (your functions). You validate the data, then cast to tell the type checker it's safe.
That's the pattern. And you use it sparingly. If you're casting a hundred times, you haven't designed your types correctly. If you're casting once per webhook handler, you're doing it right.
What about tomorrow? You said Protocol yesterday, TypedDict today. What comes next?
Tomorrow, Abstract Base Classes — nominal contracts. Protocol is structural ("if it quacks"), TypedDict is precise ("this dict has these exact fields"), ABC is nominal ("this is officially a Duck"). Three ways to think about type relationships. Three tools for three jobs.
Practice your skills
Sign up to write and run code in this lesson.
TypedDict, Literal, and NewType: Precise Types for Real-World Shapes
Type untyped webhook payloads with TypedDict. Constrain status strings with Literal. Brand order IDs as NewType. The type checker catches bugs you'd ship.
You mentioned Protocol yesterday — structural typing, quack typing. Today we're doing something different?
Today we're getting specific. Protocol asks "what shape does this object have?" TypedDict, Literal, and NewType ask "what exact contract does this payload or value follow?" Yesterday you designed duck types. Today you're writing legal contracts.
That sounds more restrictive than Protocol.
It is. And restriction is what you need when you're handling data from outside your code — webhooks, API responses, configuration files. You don't want to ask "does this look like a payment?" You want to say "this MUST be a PayPal webhook with exactly these fields, this status MUST be one of four values, and this order ID is not just any string — it's an OrderId."
So TypedDict is for dicts? I've been getting dict[str, Any] back from webhook handlers for months and the type checker just... gives up. It can't tell me what's in there.
Exactly. dict[str, Any] is a surrender. You're saying "it's a dict with string keys and values of literally any type." That's why the type checker can't help you catch bugs. Let me show you the shape of a PayPal webhook payload. This is real.
from typing import TypedDict, Literal
class WebhookPayload(TypedDict):
order_id: str
status: Literal['pending', 'paid', 'cancelled', 'refunded']
amount: float
currency: str
def process_webhook(payload: WebhookPayload) -> None:
print(f"Order {payload['order_id']} is {payload['status']}")
# If you try payload['invalid_key'], type checker errors
# If you try to assign payload['status'] = 'maybe', type checker errors
Now the type checker knows exactly what keys the dict has, what type each value is, and what the allowed values are for status. One webhook handler, one line of defense against typos and malformed data.
But dict[str, Any] is also a dict. Why do I need a separate TypedDict?
Because dict[str, Any] is a type annotation for dicts in general. TypedDict is a type annotation for a specific dict shape. Let me show the difference at runtime:
from typing import TypedDict
class WebhookPayload(TypedDict):
order_id: str
status: str
# At runtime, TypedDict returns a dict class
print(type(WebhookPayload)) # <class 'type'>
print(WebhookPayload.__annotations__) # {'order_id': <class 'str'>, 'status': <class 'str'>}
# You can use it as a type hint
payload: WebhookPayload = {"order_id": "ORD-001", "status": "paid"}
# But you cannot instantiate it like a dataclass
# WebhookPayload(order_id="ORD-001", status="paid") # TypeError
# It's just a dict, with type information attached
print(isinstance(payload, dict)) # True
print(payload["order_id"]) # "ORD-001"
TypedDict is only for type checkers. At runtime it's just a dict. That's why it's lightweight and why you use it for data that comes from outside — APIs, webhooks, config files.
So TypedDict is a type hint pretending to be a class?
TypedDict is a type hint that looks like a class. Very good description. Now: what happens when you get a webhook payload from PayPal and the status field is 'pending'? Or 'paid'? Or 'maybe_paid_if_you_squint'?
If it's 'maybe_paid_if_you_squint', that's not a real PayPal status. Your code should crash or reject it. But how does TypedDict help? TypedDict doesn't enforce the status at runtime.
TypedDict is a contract — the type checker reads it and validates that your code is only using the status in ways that are safe for a Literal. But you're right: it doesn't enforce at runtime. That's why you need the second piece. Literal.
from typing import Literal
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
# OrderStatus is a type hint, not a class.
# At runtime, OrderStatus is just a variable holding a special type object.
status: OrderStatus = 'pending' # OK
status: OrderStatus = 'maybe' # Type checker error
Literal says "this value MUST be one of these exact strings (or ints, or any literal value)." The type checker validates it. At runtime, Literal does nothing — it's just the value.
So if Literal doesn't enforce at runtime, what stops a webhook handler from receiving status='invalid'?
Nothing, unless you explicitly validate it. Type checkers only help you write correct code assuming the input is correct. They don't guard against bad input. For that, you write a validator:
from typing import TypedDict, Literal
class WebhookPayload(TypedDict):
order_id: str
status: str # Untyped at ingress
amount: float
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
def process_webhook(raw_payload: dict) -> None:
"""Accept a raw dict, validate it, then process as TypedDict."""
# Validate the status before you use it
if raw_payload['status'] not in ('pending', 'paid', 'cancelled', 'refunded'):
raise ValueError(f"Invalid status: {raw_payload['status']}")
# Now it's safe to treat as TypedDict
payload: WebhookPayload = raw_payload
# Type checker is happy; status is guaranteed one of the four values
So Literal is about type safety, not runtime safety. Runtime safety is your job — validate input, then the type checker knows you're safe.
That's the pattern. Now here's the third piece. OrderId. Right now you have:
def refund_order(order_id: str) -> None:
# Refund the order
pass
def send_email(customer_email: str) -> None:
# Send an email
pass
# Bug waiting to happen:
refund_order(send_email) # Oops, I passed the function
refund_order('alice@example.com') # Oops, I passed an email instead of order ID
Both order_id and customer_email are strings. The type checker sees no problem. But semantically, they're different things. You don't want to pass an email where an order ID is expected. You need NewType.
NewType creates a new type?
NewType creates a brand for a type. It's zero-cost — at runtime, NewType("OrderId", str) is just str. But at the type-checking level, OrderId is a distinct type from str.
from typing import NewType
OrderId = NewType('OrderId', str)
CustomerId = NewType('CustomerId', str)
def refund_order(order_id: OrderId) -> None:
print(f"Refunding {order_id}")
def send_email(customer_email: str) -> None:
pass
# Type checker error: send_email() takes str, not OrderId
refund_order(send_email) # Error
# Type checker error: refund_order() takes OrderId, not str
refund_order('alice@example.com') # Error
# Type checker error: can't pass CustomerId where OrderId is expected
customer_id: CustomerId = CustomerId('CUST-001')
refund_order(customer_id) # Error
# OK
order_id: OrderId = OrderId('ORD-001')
refund_order(order_id) # OK
At runtime, OrderId('ORD-001') just returns the string 'ORD-001'. But the type checker sees them as different.
So NewType is a type-checker-only feature? Like Literal?
Like Literal. All three are type-checker features. Type checkers don't enforce at runtime. But they catch bugs in development. Here's a real scenario: your webhook receives a payload. You extract the status and order ID. Two seconds later, you're calling refund_order(status) instead of refund_order(order_id) because you mixed up variable names. Type checkers catch this.
Okay. So the pattern for handling external data is: use TypedDict for the overall shape, Literal for constrained values, NewType for branded strings that shouldn't be confused with other strings.
That's exactly the pattern. All three together look like this:
from typing import TypedDict, Literal, NewType
OrderId = NewType('OrderId', str)
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
class WebhookPayload(TypedDict):
order_id: OrderId
status: OrderStatus
amount: float
def process_webhook(payload: WebhookPayload) -> None:
# Type checker knows order_id is OrderId, not just str
# Type checker knows status is one of four values
# Type checker knows amount is float
refund_if_pending(payload['order_id'], payload['status'])
def refund_if_pending(order_id: OrderId, status: OrderStatus) -> None:
if status == 'pending':
# refund_order takes OrderId, so you can only pass OrderId
refund_order(order_id)
def refund_order(order_id: OrderId) -> None:
print(f"Refunding {order_id}")
One line: if you ever try to pass a plain string where OrderId is required, the type checker stops you.
But creating a NewType feels verbose. Is it worth it just to catch one mistake?
Imagine your order pipeline has 47 functions that take order IDs. Imagine one developer passes a customer ID to refund_order(). No crash at runtime — it's just a string. But the wrong customer's refund goes through. The mistake cost you the customer, a support ticket, and a refund. NewType costs you two lines of code and prevents that bug class entirely.
So NewType is documentation and insurance rolled into one.
That's exactly what it is. Documentation that the type checker enforces. Now: you also need to know one edge case. When you create an OrderId, you can't do it with a plain string — you have to call the NewType function explicitly:
# Wrong:
order_id: OrderId = 'ORD-001' # Type checker error
# Right:
order_id: OrderId = OrderId('ORD-001')
# At runtime, both are just 'ORD-001', but the type checker tracks which is which
print(type(order_id)) # <class 'str'>
print(isinstance(order_id, str)) # True
NewType exists purely for the type checker. It doesn't create a new class or change the runtime type. It's a phantom brand.
So if I get an order_id from a JSON payload, I can't just assign it to an OrderId variable. I have to wrap it in OrderId()?
You have to cast it. And casting is an admission that you're doing something the type checker can't verify automatically. Here's the real pattern:
import json
from typing import TypedDict, Literal, NewType, cast
OrderId = NewType('OrderId', str)
OrderStatus = Literal['pending', 'paid', 'cancelled', 'refunded']
class WebhookPayload(TypedDict):
order_id: str # From JSON, we don't know the type yet
status: str
amount: float
def process_webhook(raw_json: str) -> None:
raw_payload = json.loads(raw_json)
# Validate that the status is one of the allowed values
if raw_payload['status'] not in ('pending', 'paid', 'cancelled', 'refunded'):
raise ValueError(f"Invalid status: {raw_payload['status']}")
# Now we can safely cast
order_id = cast(OrderId, raw_payload['order_id'])
status = cast(OrderStatus, raw_payload['status'])
amount = raw_payload['amount']
payload: WebhookPayload = {
'order_id': order_id,
'status': status,
'amount': amount,
}
refund_if_pending(order_id, status)
cast() looks like it's cheating. You're telling the type checker "trust me, this is an OrderId" even though you didn't enforce it.
You're not enforcing the type — you're enforcing the value at runtime. The type checker can't check that the JSON string really is an order ID (it's just a string). But you did validate that the status is one of four values. So you use cast to bridge the gap: "I validated this at runtime, type checker, you can trust me."
So cast is for the boundary between untyped data (JSON, webhooks) and typed code (your functions). You validate the data, then cast to tell the type checker it's safe.
That's the pattern. And you use it sparingly. If you're casting a hundred times, you haven't designed your types correctly. If you're casting once per webhook handler, you're doing it right.
What about tomorrow? You said Protocol yesterday, TypedDict today. What comes next?
Tomorrow, Abstract Base Classes — nominal contracts. Protocol is structural ("if it quacks"), TypedDict is precise ("this dict has these exact fields"), ABC is nominal ("this is officially a Duck"). Three ways to think about type relationships. Three tools for three jobs.