Skip to content

fix: NoneType parameters lose their default and their nullability - #321

Merged
lesnik512 merged 1 commit into
mainfrom
fix/nonetype-drops-default
Jul 14, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
fix/nonetype-drops-default

Conversation

@lesnik512

Copy link
Copy Markdown
Member

A creator parameter annotated None with a default raises instead of using it:

class Svc:
    def __init__(self, hook: None = None) -> None: ...   # -> ArgumentResolutionError

class Svc2:
    def __init__(self, hook: str = "d") -> None: ...     # -> resolves, hook == "d"

SignatureItem.from_type (types_parser.py:21) early-returns a bare cls() for types.NoneType, discarding both the default it was handed and the nullability the annotation implies. Every other branch threads default through via result = {"default": default}. absent_disposition then sees default is UNSET and is_nullable is False, and classifies the parameter UNWIRABLE.

x: None = None and x: None were indistinguishable after parsing — the tell that cls() was dropping state rather than encoding a decision.

The fix

Return the degenerate nullable instead of an empty record. NoneType is a union with zero non-None members, and this is exactly what the union branch below would produce for it:

if type_ is types.NoneType:
    return cls(default=default, is_nullable=True)

The branch still has to exist: without it, NoneType falls through to isinstance(type_, type) and becomes arg_type=NoneType, which would then be looked up in the registry.

Behavior

None now takes the same two branches as X | None, which were already correct:

creator before after
x: None = None UNWIRABLE (raises) OMIT (default applies)
x: None UNWIRABLE (raises) NULL (injects None)
x: str | None = None OMIT OMIT (unchanged)
x: str | None NULL NULL (unchanged)

Row 2 goes beyond the reported defect: bare x: None currently errors and will now inject None. That is the annotation's only legal value, so injecting it is what the type says to do and the error was a false positive.

In the return position (-> None, a void creator) the new nullability is simply unread — factory.py:75 consults only .arg_type to derive bound_type, so a void creator still gets bound_type=None exactly as before. Three existing test_parse_creator expectations are updated for the return-position record; no behavior rides on them.

Design: planning/changes/2026-07-14.07-nonetype-param-drops-default.md.

Not in scope

The card this came from also flagged that UnsupportedCreatorParameterError is raised from two owners — types_parser.py (positional-only) and factory.py (parameterized generic) — one policy, two homes. Deliberately left alone: it is churn without a forcing function, and the revisit trigger on decisions/2026-07-14-signatureitem-opacity-superseded.md (a new duplicated raw-field decode idiom) has not fired.

Testing

TDD, 5 failing tests written first.

  • test_signature_item_parser[NoneType]from_type(None) is nullable.
  • test_nonetype_threads_its_default — parametrized over None, 3, "x". Exercised through from_type rather than a real creator, because only None is assignable to a None-annotated parameter, so a non-None default cannot be spelled in a signature (ty correctly rejects it).
  • test_nonetype_params_keep_defaults_through_parse_creator — end-to-end through the parser.
  • test_nonetype_param_with_default_uses_the_default / ..._without_default_injects_none — resolution-level, the behavior a user actually hits.

just test-ci: 386 passed, 100% coverage. just lint-ci: clean.

🤖 Generated with Claude Code

`SignatureItem.from_type` early-returned a bare `cls()` for `types.NoneType`,
discarding both the `default` it was handed and the nullability the annotation
implies. Every other branch threads `default` through. `absent_disposition` then
saw `default is UNSET` and `is_nullable is False` and classified the parameter
UNWIRABLE, so a creator parameter annotated `None` *with a default* raised
`ArgumentResolutionError` instead of using it:

    def __init__(self, hook: None = None) -> None: ...   # -> ArgumentResolutionError
    def __init__(self, hook: str = "d") -> None: ...     # -> resolves, hook == "d"

`x: None = None` and `x: None` were indistinguishable after parsing — the tell
that `cls()` was dropping state rather than encoding a decision.

Return the degenerate nullable instead: NoneType is a union with zero non-None
members, which is exactly what the union branch would produce for it. The branch
still has to exist — without it NoneType falls through to `isinstance(type_,
type)` and gets looked up in the registry as `arg_type`.

`None` now takes the same two branches as `X | None`: `x: None = None` omits (its
default applies), and bare `x: None` injects None — the annotation's only legal
value, where before it raised. In the return position (`-> None`) the nullability
is unread; only `.arg_type` is consulted, to derive `bound_type`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lesnik512
lesnik512 merged commit 1f0f463 into main Jul 14, 2026
7 checks passed
@lesnik512
lesnik512 deleted the fix/nonetype-drops-default branch July 14, 2026 19:38
lesnik512 added a commit that referenced this pull request Jul 17, 2026
Cover the 2.28.0->2.29.0 backlog: the single-path compiled resolver (#334)
and its measured perf, the wiring-plan memoization (#326), the three
correctness fixes (#340 ContextProvider-via-kwargs default, #321 NoneType
default/nullability, #320 validate traverses kwargs=), and the closed
provider set under an explicit breaking-change heading (per
planning/decisions/2026-07-17-custom-providers-retracted.md, the release
notes carry that warning, not the docs).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant