Skip to content

Static input enforcement on TagList[TagifiedNode] via self-typed overloads #115

@schloerke

Description

@schloerke

Context

Following #105/#106 (PR that made Tag/TagList generic in TagNodeT) and #112 / commit a9ec156 (which added a runtime TypeError at the TagList.tagify() boundary when a child's .tagify() returned un-tagified content), there is a remaining static-typing gap:

t: TagList[TagifiedNode] = some_taglist.tagify()
t.append(div("hi"))            # silently allowed by pyright today
t.get_html_string()            # works (div is already tagified — happens to be fine)
t.append(SomeTagifiable())     # also silently allowed at .append() time
t.get_html_string()            # raises RuntimeError at render

The append of an un-tagified Tagifiable to a TagList[TagifiedNode] is statically silent. It only surfaces at render time, or — if .tagify() is re-run — at the boundary check from #112.

Proposal

Keep TagNodeT = TypeVar(\"TagNodeT\", bound=TagNode, default=TagNode) as-is (do not narrow to a constrained TypeVar(..., TagNode, TagifiedNode)), and add Self-typed @overload signatures to TagList's input methods:

TagifiedChild = Union[
    TagifiedNode,
    \"TagList[TagifiedNode]\",
    float,
    None,
    Sequence[\"TagifiedChild\"],
]  # non-generic alias parallel to TagChild — avoids the recursive-generic
   # pyright leak that motivated keeping TagChild non-generic in #105.

class TagList(UserList[TagNodeT]):
    @overload
    def append(self: \"TagList[TagifiedNode]\", item: TagifiedChild, *args: TagifiedChild) -> None: ...
    @overload
    def append(self: \"TagList[TagNode]\", item: TagChild, *args: TagChild) -> None: ...
    def append(self, item, *args): ...

Apply to: __init__, append, extend, insert, __add__, __radd__ (and consider __setitem__ for completeness).

Decisions reached in design discussion

  • Keep bound=TagNode (not constrained). Survey of 9 Posit Python repos (py-shiny, py-shinywidgets, py-shinyswatch, shinychat, chatlas, querychat, py-shinylive, py-shiny-templates, py-shiny-output-binding-example) found 0 in-the-wild parameterizations of TagList[T] or Tag[T] — so a constrained TypeVar would have no practical cost today, but keeping bound preserves theoretical room for TagList[MySpecificTag] at zero extra design cost.
  • TagList only — not Tag. Tag(Generic[TagNodeT]) has children: TagList[TagNodeT], so overloads on TagList cover tag.children.append(...) transitively. Direct construction of Tag[TagifiedNode](...) only happens internally via casts, never via user code.
  • Static-only enforcement. No new runtime TypeError on .append(). The existing render-time RuntimeError and the feat: TagList.tagify() raises TypeError on un-tagified content #112 tagify-boundary TypeError remain the runtime safety net. Users who # type: ignore the static error are knowingly opting out.

Why this wasn't done in #105

The note at _core.py:187-199 explains: the previous attempt made TagChild itself a generic TypeAliasType with a recursive Sequence[\"TagChild[TagNodeT]\"] arm, which leaked Sequence[Unknown] into every Tag function signature in downstream strict-mode pyright (2500+ reports in Shiny CI). A non-generic parallel alias TagifiedChild sidesteps that — it's a separate union, not a parameterization of TagChild. Worth verifying against a fresh Shiny strict-mode CI run before merging.

Open question — subclass vs. alias and the variance trap

TagifiedTagList is currently a type alias:

TagifiedTagList = TypeAliasType(\"TagifiedTagList\", \"TagList[TagifiedNode]\")

We discussed replacing it with a real subclass: class TagifiedTagList(TagList[TagifiedNode]): .... Possible, but TagList is invariant (mutable container), so TagList[TagifiedNode] is not assignable to TagList[TagNode]. With the subclass approach, every function annotated def f(t: TagList) (= TagList[TagNode]) would reject the output of .tagify().

The alias should have the same variance issue in principle — but in practice it may not surface because most consumers use bare TagList, which pyright resolves via the default. Needs spot-check of real consumers of .tagify()'s return before deciding whether subclass would be a net improvement, or whether the alias is correct as-is.

Subclassing the union types themselves (TagifiedNode, Tagified) is not possible — you can't subclass a Union.

Scope checklist

  • Define non-generic TagifiedChild alias next to TagChild.
  • Add Self-typed @overloads to TagList.{__init__, append, extend, insert, __add__, __radd__}. Evaluate __setitem__.
  • Update tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable — that test's premise (referenced in _core.py:198) inverts under this proposal: appending a Tagifiable to a TagList[TagifiedNode] should now be a static error. Decide whether to flip the test to use assert_type / reveal_type patterns or a pyright-on-fixture test.
  • Verify no Sequence[Unknown] regression in Shiny strict-mode CI from the new TagifiedChild alias.
  • Investigate the variance question above and decide subclass vs. alias for TagifiedTagList (potentially out-of-scope as a follow-up).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions