Skip to content

Make the library more strongly typed #5272

Open

Description

@harshil21

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.

Activity

  1. added this to the v23 milestone on Jun 17, 2026
  2. updated Priority Low and Effort Unknown on Jun 17, 2026
  3. ChetanyaRathi commented on Jul 8, 2026

    @ChetanyaRathi

    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?

  4. harshil21 commented on Jul 8, 2026

    @harshil21
    MemberAuthor

    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.

  5. ChetanyaRathi commented on Jul 8, 2026

    @ChetanyaRathi

    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

    1. 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.)
    2. 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.
    3. 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.
    4. 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.
    5. 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

    1. Accessor name: effective_message_or_raise(), require_effective_message(), or get_effective_message()?
    2. v1 scope: TEXT, COMMAND, PHOTO, Document, CAPTION + CallbackQuery/InlineQuery base cases — enough?
    3. Accessor error: ValueError, RuntimeError, or a PTB-specific error?

    If B+C sounds right, I'll open a PR scoped to that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fields

    Priority

    Low

    Effort

    Unknown

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions