Skip to content

Make ValueElement and ValueChangeEventArguments generic#5785

Open
evnchn wants to merge 26 commits intozauberzeug:mainfrom
evnchn:value-v
Open

Make ValueElement and ValueChangeEventArguments generic#5785
evnchn wants to merge 26 commits intozauberzeug:mainfrom
evnchn:value-v

Conversation

@evnchn
Copy link
Copy Markdown
Collaborator

@evnchn evnchn commented Feb 14, 2026

Motivation

Closes #2677.

Type checkers currently infer Any for .value on every ValueElement subclass and for e.value / e.previous_value in change handler callbacks. This makes static analysis ineffective for the most commonly accessed property in NiceGUI.

This PR makes ValueElement generic (ValueElement[V]) and ValueChangeEventArguments generic (ValueChangeEventArguments[ValueT]) so that type checkers can properly infer value types — e.g. ui.input().valuestr, ui.slider().valuefloat | None, ui.switch().valuebool | None.

Implementation

Commit 1 — Make ValueElement generic with TypeVar V:

  • Add V = TypeVar('V') and Generic[V] base class to ValueElement
  • Use a TYPE_CHECKING-only value: V annotation to override the BindableProperty descriptor for type checkers (the descriptor continues to work at runtime)
  • Update all method signatures (set_value, _handle_value_change, _event_args_to_value, bind_value_to/from, etc.) to use V
  • Make ValidationElement propagate V (ValidationElement(ValueElement[V]))
  • Pin ChoiceElement to Any since choice values are runtime-dependent
  • Parameterize all ~35 leaf element subclasses with their concrete value types

Commit 2 — Make ValueChangeEventArguments generic with TypeVar ValueT:

  • Add ValueT = TypeVar('ValueT') and Generic[ValueT] to the dataclass
  • Type value and previous_value fields as ValueT
  • Update all Handler[ValueChangeEventArguments] annotations across all elements to carry the correct type parameter

Key design decisions:

  • BindableProperty is not made generic — Python's descriptor protocol cannot infer the return type from the owner's generic parameter. The TYPE_CHECKING annotation override is the standard workaround.
  • Elements with complex/heterogeneous value types (Date, Tabs, Carousel, Stepper, Select, etc.) use Any as their type parameter, which preserves the current behavior without making incorrect type claims.
  • This is a typing-only changeGeneric[V] has no runtime effect, and the TYPE_CHECKING block is never executed.

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • The implementation is complete.
  • If this PR addresses a security issue, it has been coordinated via the security advisory process.
  • Pytests have been added (or are not necessary).
  • Documentation has been added (or is not necessary).

Add Generic[V] to ValueElement so type checkers can infer the correct
type for .value on every element subclass (e.g. Input.value -> str,
Slider.value -> float | None). Parameterize all ~35 leaf element
subclasses and propagate V through ValidationElement and ChoiceElement.

Ref: zauberzeug#2677
Add Generic[ValueT] to ValueChangeEventArguments so type checkers can
infer the correct type for e.value and e.previous_value in change
handler callbacks. Update all Handler[ValueChangeEventArguments]
signatures across ~35 element files to use the parameterized form.
@evnchn evnchn added the feature Type/scope: New or intentionally changed behavior label Feb 14, 2026
@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Feb 14, 2026

Why BindableProperty isn't made generic (and why that's fine)

Ref: #3988

TL;DR: Python's descriptor protocol can't infer the return type from the owner class's generic parameter. The TYPE_CHECKING annotation override used in this PR is the standard workaround (same pattern as SQLAlchemy, Django, Pydantic). No Python PEP currently addresses this gap.

Full analysis

The problem

BindableProperty is a descriptor. Its __get__ signature is:

def __get__(self, owner: Any, owner_cls=None) -> Any

For it to return V (the value type), it would need to know that it lives on a ValueElement[V] and that V is bound to e.g. float | None for Slider. Python's type system has no way to express "return the generic parameter of my owner class."

Approaches considered

Approach A — BindableProperty(Generic[T]) with __get__ overloads:

class BindableProperty(Generic[T]):
    @overload
    def __get__(self, owner: None, owner_cls: type) -> Self: ...
    @overload
    def __get__(self, owner: Any, owner_cls: type) -> T: ...

Problem: T is fixed at descriptor definition time, not at owner class time. You'd need value = BindableProperty[float | None](...) in every element class, defeating the purpose of inheriting the type from the class generic.

Approach B — Per-class __get__ return type via __class_getitem__:

Not supported. Descriptors can't conditionally return different types based on the class they're accessed from.

Approach C — Protocol-based descriptor typing (PEP 688 / future):

Some type checkers (pyright) have experimental heuristics, but nothing is standardized and it doesn't work with class-level generic parameters.

What we did instead (Approach D)

value = BindableProperty(on_change=...)
if TYPE_CHECKING:
    value: V  # type: ignore[assignment]

This gives correct type inference in all type checkers, has zero runtime cost, and is one line. The # type: ignore is the only cosmetic downside.

Difficulty assessment

Factor Assessment
Conceptual complexity Very high — "descriptor + owner generic" inference is unsolved
Python type system support Insufficient — no PEP covers this
Type checker support Partial (pyright has heuristics, mypy doesn't)
Scope if attempted 15+ mixin classes with their own BindableProperty
Benefit over current workaround Marginal — TYPE_CHECKING override already works

Recommendation

Don't pursue. A proper solution requires a future PEP for descriptor-generic inference. The same TYPE_CHECKING pattern could optionally be applied to other BindableProperty usages (text: str, label: str, visible: bool, enabled: bool, etc.) in a follow-up PR for completeness.

@falkoschindler falkoschindler added the in progress Status: Someone is working on it label Feb 16, 2026
@falkoschindler falkoschindler added this to the 3.9 milestone Feb 16, 2026
@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Feb 16, 2026

I'll sit until 3.9 until getting around to fix the mypy.

If anyone want to take a shot, feel free to do so (open a PR against my branch, I suppose)

@evnchn evnchn added help wanted Status: Author is inactive, others are welcome to jump in and removed in progress Status: Someone is working on it labels Feb 17, 2026
Copilot AI and others added 2 commits March 15, 2026 02:49
* Initial plan

* Fix mypy errors in value_element, selectable_element, and drawer

Co-authored-by: evnchn <37951241+evnchn@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: evnchn <37951241+evnchn@users.noreply.github.com>
@evnchn evnchn added review Status: PR is open and needs review and removed help wanted Status: Author is inactive, others are welcome to jump in labels Mar 14, 2026
@falkoschindler falkoschindler modified the milestones: 3.9, 3.10 Mar 17, 2026
@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Mar 29, 2026

Yeah indeed this is OK for review. I forgot that I asked copilot to fix the mypy errors.

@falkoschindler falkoschindler self-requested a review March 29, 2026 20:23
Copy link
Copy Markdown
Contributor

@falkoschindler falkoschindler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the pull request, @evnchn! The overall approach is sound, but replacing Any with concrete types is deceptively subtle — each element's actual runtime value type must be carefully verified, not just inferred from its __init__ signature. Here are the cases that need attention. (Note that Claude found none of them, but - of course - helped to write them down.)

  1. Some value types don't account for None from Quasar

    Elements like Input, ColorInput, DateInput, TimeInput are typed as ValueElement[str], but at runtime Quasar sends None when a clearable input is cleared. So .value becomes None even though the type says str.

    For example, i = ui.input().props('clearable') — after clearing, type(i.value) is NoneType. The type parameter should be str | None to reflect reality, or the element needs to coerce None back to '' in _event_args_to_value.

    This applies to any element where the Vue component can emit null but the type parameter claims otherwise. Worth auditing all leaf elements to ensure the type parameter matches what actually happens at runtime, not just what __init__ accepts.

  2. Elements like Carousel should use str | None, not Any

    Carousel, Stepper, Tabs, and TabPanels use ValueElement[Any] but their .value is always str | None at runtime (element references get converted to name strings in _value_to_model_value). They should be ValueElement[str | None] with set_value overridden to also accept the element type, e.g. set_value(self, value: str | CarouselSlide | None). This keeps .value correctly typed while still allowing element references as input.

  3. Tree and Table callbacks use Any but have known concrete types

    Tree and Table don't extend ValueElement — they manage their own handlers and construct ValueChangeEventArguments directly. Each callback has a distinct, known value type:

    • Tree.on_select: str | None (single node key)
    • Tree.on_expand: list[str] (expanded node keys)
    • Tree.on_tick: list[str] (ticked node keys)
    • Table.on_pagination_change: dict (pagination object)

    All currently use ValueChangeEventArguments[Any]. They should use their concrete types.

  4. Default value for inputs: '' vs None

    Currently ui.input() defaults to value=''. But None would be more semantically correct — "no value yet" is different from "user entered an empty string." It also aligns with how Quasar already treats cleared inputs (sends null). With None as default, the type naturally becomes str | None and clearable behavior is no longer a special case.

    This is a breaking change (code like ui.input().value.upper() would need a None check), so it belongs in NiceGUI 4.0.

  5. Line length violations

    Some files like selectable_element.py have lines that exceed the project's line length limit. Since the changes were likely made with AI tooling, VS Code's auto-formatter didn't run on save. Either all changed files need to be opened and saved, or a pre-commit hook should enforce formatting automatically.

  6. Rename V to ValueT

    The TypeVar V in value_element.py is imported from other files like validation_element.py. Since it crosses module boundaries, a more descriptive name like ValueT would be clearer — and consistent with the ValueT already used in events.py.

@falkoschindler falkoschindler modified the milestones: 3.10, 3.11 Apr 3, 2026
evnchn and others added 9 commits April 4, 2026 08:54
)

* Address review: rename V to ValueT, fix None coercion, concrete types for Tree/Table

- Rename TypeVar V to ValueT in value_element.py, import from events.py for consistency
- Update validation_element.py to use ValueT
- Carousel/Stepper/Tabs/TabPanels: use ValueElement[str | None] with set_value overrides
- Tree: concrete types for on_select (str | None), on_expand/on_tick (list[str])
- Table: concrete type for on_pagination_change (dict)
- Input/ColorInput/DateInput/TimeInput/Editor: coerce None to empty string in _event_args_to_value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: use str | None type instead of coercing None for clearable inputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: evnchn <evnchn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ateInput, TimeInput

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nt[str | None]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ue_to_event_value returns ValueT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
falkoschindler and others added 6 commits April 5, 2026 18:20
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
None is accepted as input but coerced to [] before storing,
so the actual .value is always list[str].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@falkoschindler falkoschindler self-requested a review April 5, 2026 16:50
falkoschindler and others added 6 commits April 7, 2026 16:48
Accept element objects (CarouselSlide, Step, Tab, TabPanel) in the
ValueElement type parameter so .value typing is honest. This removes
the need for set_value overrides and __init__ pre-conversions since
_value_to_model_value handles all conversion to strings for the
frontend.

Split CarouselSlide, Step, StepperNavigation, Tab, TabPanel, and
TabPanels into separate files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The value type depends on runtime props (range, multiple), similar
to how ChoiceElement values depend on runtime options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants