You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I'm writing this down as more of a general issue, I don't have anything concrete or specific to change just yet.
Background
I was recently playing around with the polars library, and I was surprised by how good the typing is. If there are any typing errors in your select query for example, the code would certainly fail at runtime. They support a lot of different operations and codepaths and it all works great with the type system (unlike pandas). That got me thinking if we can make PTB better typed, so there's more confidence that there's no runtime errors when you run your bot for the first time.
Potential area of improvement
The biggest area of improvement I see is definitely the handler (with filters) -> callback interface. For e.g. when opening echobot.py, ty gives me a bunch of typing issues in every callback, mostly because we are accessing attributes which could be None. We know they would work at runtime because we have a filter which filters them only when they are not None. The typing system does not know this.
Possible Solution?
I remember discussing this a long time ago in the dev chat, and I think at the time we could not resolve it, since it required making the Update class Generic? Or adding a bunch of type casts everywhere.
I wonder if there is now a better solution to this considering all the new typing features of recent python versions (3.11+). I'm still not that well versed in typing to come up with the best solution yet.
This is a great opportunity for a new contributor to give their ideas on how to fix this.
Hi! I'd like to take this on. Since it reads more like a design question than a
straightforward fix, would it be okay if I start by writing up a short proposal
(e.g. making Update generic vs. narrowing the callback's type via overloads for
the common filter cases) before opening a PR?
would it be okay if I start by writing up a short proposal
(e.g. making Update generic vs. narrowing the callback's type via overloads for
the common filter cases) before opening a PR?
Thanks for the interest! Yes, a proposal is required first. It should go into the complexity of the change, estimated LoC, how type checkers will resolve it, edge cases, etc.
Thanks you, Here is the proposal. I built a small prototype locally to test the idea, and one finding changed the design (the effective_message point in edge cases).
The main constraint
A type checker only looks at the type you write for update in each callback. It cannot see the filter attached to the handler. So update.message.text gives an error because the checker sees update: Update, where .message is Message | None. The filter makes it non-None at runtime, but that fact never reaches the callback's type.
This means the only fix is: give users narrower types to write on the callback, and make the handlers produce or accept them. So "make Update generic" and "narrow with overloads" are not two rival fixes — they are two ways to make those narrower types. The user always has to opt in by annotating the callback.
How type checkers resolve it (current state)
Baseline uvx ty check examples/echobot.py on master (ty 0.0.57):
error[unresolved-attribute]: reply_html not defined on None in Message | None echobot.py:38
error[unresolved-attribute]: mention_html not defined on None in User | None echobot.py:39
error[unresolved-attribute]: reply_text not defined on None in Message | None echobot.py:46
error[unresolved-attribute]: text not defined on None in Message | None echobot.py:51
error[invalid-argument-type]: expected str, found str | None echobot.py:51
(6 total)
Fix: add small Update subtypes (e.g. UpdateExtMessageText) that redeclare the relevant attribute as non-None. The user writes async def echo(update: UpdateExtMessageText, ...), and the checker resolves the access through the subtype. 6 errors → 0, with no runtime change. On the handler side, add @overloads to __init__ so that a known single filter like filters.TEXT picks the matching narrowed type, and any unknown or combined filter falls back to plain Update.
The three options
A — Make Update generic (Update[M]). Add a type parameter for the message type, with a default (PEP 696) so update: Update still works. Problem: to narrow message.text you also need narrowed Message subtypes, because that guarantee lives on Message, not Update. So you maintain a family of subtypes and thread the type parameter through effective_message, the handlers, and the filters. The default also needs typing_extensions on Python 3.10–3.12. Most invasive, highest risk to existing code.
B — Narrowed views + handler overloads (recommended core). A small set of Update subtypes plus @overloaded handler __init__. Contained, additive, and backward compatible because there is a fallback Update overload.
C — Typed accessor methods (recommended escape hatch). e.g. Update.effective_message_or_raise() -> Message. Works no matter how complex the filter is; small; a bit more verbose. Covers the combined-filter cases that B cannot.
Recommendation: B as the core + C as the escape hatch. Narrowing arbitrary combined filters is out of scope (see edge cases).
Edge cases
Narrow effective_message, not message.MessageFilter.check_update passes update.effective_message to .filter() (filters.py:329), and effective_message can come from message, edited_message, channel_post, edited_channel_post, business_message, guest_message, or callback_query.message. So a filters.TEXT match does not prove update.message is not None — an edited text message has message=None. Narrowing update.message would type-check but crash at runtime on edits. The views must redeclare effective_message. (This also means echobot's update.message.text is technically unsound for edited messages.)
No intersection types.filters.TEXT & filters.ChatType.PRIVATE would need a TextMessage & PrivateChatMessage type, which Python cannot express today. So combined & filters fall back to plain Update (the old errors come back, but nothing false is allowed). | gives a union; ~ can't be expressed. We narrow a fixed, listed set of single filters and fall back to Update for the rest.
Backward compatibility. An old callback written as async def cb(update: Update, ...) must still work where a handler expects a narrowed type. The fallback Update overload covers this. Hard requirement.
Too many combinations. Single filters (TEXT, PHOTO, VIDEO, DOCUMENT, CAPTION…) times {message, edited, channel_post…} is a big matrix. v1 covers only the common few.
Runtime cost: zero. Everything here is types only.
Estimated LoC
filters.py is ~3,200 lines and Update has ~58 nullable attributes, so the typing surface is the main cost.
A (generic): several hundred to 1000+ lines.
B (views + overloads): ~150–400, additive.
C (accessors): ~50–150.
Open questions
Accessor name: effective_message_or_raise(), require_effective_message(), or get_effective_message()?
Activity
ChetanyaRathi commentedon Jul 8, 2026
Hi! I'd like to take this on. Since it reads more like a design question than a
straightforward fix, would it be okay if I start by writing up a short proposal
(e.g. making
Updategeneric vs. narrowing the callback's type via overloads forthe common filter cases) before opening a PR?
harshil21 commentedon Jul 8, 2026
Thanks for the interest! Yes, a proposal is required first. It should go into the complexity of the change, estimated LoC, how type checkers will resolve it, edge cases, etc.
ChetanyaRathi commentedon Jul 8, 2026
Thanks you, Here is the proposal. I built a small prototype locally to test the idea, and one finding changed the design (the
effective_messagepoint in edge cases).The main constraint
A type checker only looks at the type you write for
updatein each callback. It cannot see the filter attached to the handler. Soupdate.message.textgives an error because the checker seesupdate: Update, where.messageisMessage | None. The filter makes it non-Noneat runtime, but that fact never reaches the callback's type.This means the only fix is: give users narrower types to write on the callback, and make the handlers produce or accept them. So "make
Updategeneric" and "narrow with overloads" are not two rival fixes — they are two ways to make those narrower types. The user always has to opt in by annotating the callback.How type checkers resolve it (current state)
Baseline
uvx ty check examples/echobot.pyon master (ty 0.0.57):Fix: add small
Updatesubtypes (e.g.UpdateExtMessageText) that redeclare the relevant attribute as non-None. The user writesasync def echo(update: UpdateExtMessageText, ...), and the checker resolves the access through the subtype. 6 errors → 0, with no runtime change. On the handler side, add@overloads to__init__so that a known single filter likefilters.TEXTpicks the matching narrowed type, and any unknown or combined filter falls back to plainUpdate.The three options
A — Make
Updategeneric (Update[M]). Add a type parameter for the message type, with a default (PEP 696) soupdate: Updatestill works. Problem: to narrowmessage.textyou also need narrowedMessagesubtypes, because that guarantee lives onMessage, notUpdate. So you maintain a family of subtypes and thread the type parameter througheffective_message, the handlers, and the filters. The default also needstyping_extensionson Python 3.10–3.12. Most invasive, highest risk to existing code.B — Narrowed views + handler overloads (recommended core). A small set of
Updatesubtypes plus@overloaded handler__init__. Contained, additive, and backward compatible because there is a fallbackUpdateoverload.C — Typed accessor methods (recommended escape hatch). e.g.
Update.effective_message_or_raise() -> Message. Works no matter how complex the filter is; small; a bit more verbose. Covers the combined-filter cases that B cannot.Recommendation: B as the core + C as the escape hatch. Narrowing arbitrary combined filters is out of scope (see edge cases).
Edge cases
effective_message, notmessage.MessageFilter.check_updatepassesupdate.effective_messageto.filter()(filters.py:329), andeffective_messagecan come frommessage,edited_message,channel_post,edited_channel_post,business_message,guest_message, orcallback_query.message. So afilters.TEXTmatch does not proveupdate.messageis notNone— an edited text message hasmessage=None. Narrowingupdate.messagewould type-check but crash at runtime on edits. The views must redeclareeffective_message. (This also meansechobot'supdate.message.textis technically unsound for edited messages.)filters.TEXT & filters.ChatType.PRIVATEwould need aTextMessage & PrivateChatMessagetype, which Python cannot express today. So combined&filters fall back to plainUpdate(the old errors come back, but nothing false is allowed).|gives a union;~can't be expressed. We narrow a fixed, listed set of single filters and fall back toUpdatefor the rest.async def cb(update: Update, ...)must still work where a handler expects a narrowed type. The fallbackUpdateoverload covers this. Hard requirement.Estimated LoC
filters.pyis ~3,200 lines andUpdatehas ~58 nullable attributes, so the typing surface is the main cost.Open questions
effective_message_or_raise(),require_effective_message(), orget_effective_message()?ValueError,RuntimeError, or a PTB-specific error?If B+C sounds right, I'll open a PR scoped to that.