`**dict[str, Any]` unpack in a dict literal defeats anonymous-TypedDict inference; the splat then fails (mypy and Pyright accept)
- Dominant language
- Rust
- Stars
- 7k
- Forks
- 516
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
Unpacking a `Mapping[str, Any]` into a dict literal makes Pyrefly discard the per-key
types of that literal's own keys — even when those keys are written *last* and
definitively overwrite anything the mapping supplied. The literal collapses to a
homogeneous `dict[str, bool | int | str | Any]`, and splatting it into a typed callable
then fails. Mypy and Pyright let the `Any` contribution dominate the value-type join
(`dict[str, Any]`) and accept.
```python
import dataclasses
from typing import Any
@dataclasses.dataclass
class Q:
a: str
b: bool
def helper() -> dict[str, Any]:
return {"a": "z"}
def f() -> Q:
return Q(**{**helper(), "a": "x", "b": True})
# Pyrefly 1.2.0 : 2x bad-argument-type (a: str, b: bool)
# mypy --strict : ok
# Pyright : ok
```
`helper()` is unpacked *first*, so `"a"` and `"b"` are statically known to be `str` and
`bool` after the merge. Pyrefly reports:
```
Unpacked keyword argument `bool | str | Any` is not assignable to
parameter `a` with type `str` in function `Q.__init__` [bad-argument-type]
```
**The ask:** accept the splat in both merge orders. The two orders differ only in how
much per-key precision is recoverable — helper-first can retain `a: str, b: bool`;
literals-first cannot retain anything, because the helper may overwrite any key — but in
both cases the unmatched contribution is `Any`, so the call should pass.
## Why it matters
This is the ordinary options/defaults idiom — `f(**{**DEFAULTS, **overrides})`, config
layering, kwargs forwarding — and `dict[str, Any]` is the near-universal return type of
the thing on the left. The pattern is hard to avoid when the helper's values are
genuinely heterogeneous (one key `int`, another `bool`, another `dict[str, float]`,
another `str | None`): declaring every per-key type defeats the point, and mirroring a
wide dataclass into a `TypedDict` duplicates the type information and fixes exactly one
target.
Concretely, in one ~1,100-entry Pyrefly baseline for a large Python codebase adopting
Pyrefly alongside mypy and Pyright, **337 of 1,122 baselined entries (30%) carry this
signature** — half of all 675 `bad-argument-type` entries. Those 337 entries come from
just **26 call sites across 13 files**: a single splat against a wide dataclass emits one
entry per unmatched field, so a small number of real sites dominates the baseline. It is
the largest single family in that baseline, and the one blocking further narrowing.
## Relation to #2571 and PEP 728
[#2571](https://github.com/facebook/pyrefly/issues/2571) ("support unpack/spread of
anonymous typed dicts into dict literals") already relaxed the rule that *any* unpacking
defeats anonymous-TypedDict inference: unpacked values that are themselves anonymous
typed dicts are merged field-by-field, later fields overriding earlier ones
(`expr.rs:1751-1761`). This request is the next increment on the same axis — extend that
merge to ordinary `Mapping[str, Any]` unpacks, where the merged-in fields are `Any`
rather than known. The later-fields-override semantics #2571 already implements are
exactly what makes the helper-first case safe.
[PEP 728](https://peps.python.org/pep-0728/) ("TypedDict with Typed Extra Items", Final,
3.15) standardises the vocabulary for the shape being asked for: a TypedDict with known
items plus an `extra_items` type. What this issue requests is the *anonymous* analogue —
known literal-keyed fields plus `extra_items=Any` contributed by the unpack.
The typing spec does not mandate either behaviour here. But mypy and Pyright agree with
each other, and Pyrefly additionally disagrees with both under `assert_type`, so this is
a de facto ecosystem expectation rather than a matter of taste.
## Expected behaviour
Helper first — `{**helper(), "a": "x", "b": True, "c": 7}`:
```
known fields: a: str, b: bool, c: int # literals written last; definitely these
extra fields: Any # anything else helper() supplied
```
Literals first — `{"a": "x", "b": True, "c": 7, **helper()}`:
```
known fields: (none; helper() may overwrite any of them)
extra fields: Any
```
Both accept the splat: the first per-key, the second via `Any`. This matches the
`dict[str, Any]` that mypy and Pyright already infer for the second form.
**Non-`Any` mappings should keep working as they do now.** For `helper() -> dict[str,
int]`, helper-first would give known fields `a: str, b: bool, c: int` with extras `int`;
literals-first would give no known fields and extras `int`, and the splat against `Q`
should still *fail*. The request is about `Any` no longer poisoning the per-key
correlation, not about making unpacks permissive in general.
## Full case matrix
Seven cases covering context, controls, and both merge orders (click to expand)
Verified under Pyrefly 1.2.0 (`legacy`/`default`/`strict`), mypy 2.3.0 `--strict`, and
Pyright 1.1.411. The block below is runnable as-is.
```python
from __future__ import annotations
import dataclasses
from typing import Any, assert_type
@dataclasses.dataclass
class Q:
a: str
b: bool
c: int
def _helper() -> dict[str, Any]:
return {"a": "z"}
# A: pure heterogeneous literal, no unpack — the shape Pyrefly retains.
def case_A() -> Q:
qmd = {"a": "x", "b": True, "c": 7}
return Q(**qmd)
# B: pure-literal per-key mismatch. Genuine error; must keep being caught.
def case_B() -> None:
qmd = {"a": "x", "b": True, "c": "x"} # c is str, not int
Q(**qmd)
# C1: helper first, literals last — PRIMARY.
def case_C1_helper_first() -> Q:
return Q(**{**_helper(), "a": "x", "b": True, "c": 7})
# C2: literals first, helper last — secondary; less precision recoverable.
def case_C2_helper_last() -> Q:
return Q(**{"a": "x", "b": True, "c": 7, **_helper()})
# D/E: assert_type exhibits of the inference fork.
def case_D() -> None:
qmd = {"a": "x", "b": True, "c": 7, **_helper()}
assert_type(qmd, dict[str, Any])
def case_E() -> None:
qmd = {"a": "x", "b": True, "c": 7, **_helper()}
val = qmd["a"]
assert_type(val, Any)
# F: explicit dict[str, object] annotation — must keep failing everywhere.
def case_F() -> None:
qmd: dict[str, object] = {"a": "x", "b": True, "c": 7}
Q(**qmd)
```
| Case | Pyrefly 1.2.0 | mypy 2.3.0 `--strict` | Pyright 1.1.411 |
|---|---:|---:|---:|
| `case_A` — literal, no unpack | 0 | 3 (widens to `dict[str, object]`) | 0 |
| `case_B` — per-key mismatch | **1 (only Pyrefly catches this)** | 3 (widens) | 0 |
| `case_C1` — **primary** | **3** | 0 | 0 |
| `case_C2` — secondary | 3 | 0 | 0 |
| `case_D` — `assert_type` | 1 (`assert-type`) | 0 | 0 |
| `case_E` — `assert_type` | 1 (`assert-type`) | 0 | 0 |
| `case_F` — `object` control | 3 | 3 | 3 |
`case_B` is the constraint on any fix: Pyrefly is the only one of the three that catches
that genuine per-key mismatch, and that should not be lost. `case_F` likewise must keep
failing — only `Any`-sourced unpacks should benefit.
Pyrefly's `assert-type` messages name the inferred type directly:
```
assert_type(dict[str, bool | int | str | Any], dict[str, Any]) failed [assert-type]
assert_type(bool | int | str | Any, Any) failed [assert-type]
```
```bash
pyrefly check repro.py --preset default --output-format=min-text --baseline /dev/null
# INFO 12 errors
mypy --strict repro.py # Found 9 errors in 1 file
pyright repro.py # 3 errors (all in case_F)
```
Identical under `legacy`, `default`, and `strict`. `basic` does not enable
`bad-argument-type`, so it reports nothing. No case raises at runtime — the dataclass
does not enforce annotations — so the repro is purely a checker disagreement, with no
exception masking the difference.
## Implementation pointers
Line numbers against the 1.2.0 tag.
The fork is in `dict_items_infer_inner` (`pyrefly/lib/alt/expr.rs:1650`). The
mapping-unpack arm disables anonymous-TypedDict construction (`expr.rs:1762-1764`):
```rust
} else if let Some((key_t, value_t)) = self.unwrap_mapping(&ty) {
// Non-anonymous-typed-dict unpacking disables anonymous typed dict creation
can_create_anonymous_typed_dict = false;
```
directly below the #2571 arm that *does* merge anonymous typed dicts field-by-field
(`expr.rs:1751-1761`). All collected value types are then unioned (`expr.rs:1820`):
```rust
let value_ty = self.unions(value_tys);
```
yielding `dict[str, bool | int | str | Any]`. By the time `callable.rs` sees it, the
value type is already collapsed: it is stored in `splat_kwargs` (`callable.rs:1169`) and
checked against every unmatched named parameter (`callable.rs:1421`), which is why one
splat emits one error per parameter.
Two possible shapes of fix, either of which resolves every case above:
1. **Open anonymous shape** — extend `AnonymousTypedDictInner`
(`crates/pyrefly_types/src/typed_dict.rs:88`) with an extra-items type, and make the
mapping-unpack arm contribute extras instead of defeating the shape, with
order-sensitive overwrite handling. Larger, but it generalises machinery #2571 already
introduced and is the PEP 728 shape.
2. **`Any`-dominance in the join** — when the unpacked mapping's value type is exactly
`Any`, let it dominate the value-type join rather than participate in it, so the
display infers `dict[str, Any]`. Much smaller, and it is the same trade-off mypy and
Pyright already make; the cost is losing the concrete alternatives in the
literals-first case, where they are arguably useful.
Either would unblock this. (1) additionally preserves per-key precision in the
helper-first order — a benefit to #2571's users generally, not something this report
specifically needs.
## Non-goals
- **Not** weakening union assignability so that "any union containing `Any` passes".
`int | Any` is a meaningful gradual type; Pyrefly, mypy, and Pyright all correctly
reject `y: str = x` for `x: int | Any` (verified). A fix should stay local to
dict-literal inference.
- **Not** per-key preservation without order handling. `b: bool` genuinely is *not*
preserved in `{"b": True, **helper()}`; order-sensitivity is the crux.
- **Not** a claim that the current behaviour is unsound. Each inference step is
internally consistent — this is about precision, and about Pyrefly being the only one
of three major checkers to reject a mainstream pattern.
## Workarounds
Annotating the local — `qmd: dict[str, Any] = {..., **helper()}` — passes all three
checkers, at one line per site. It works by manually discarding the precision, i.e. by
applying fix (2) by hand. Otherwise a per-site `# pyrefly: ignore[bad-argument-type]` is
needed when the literal is built inline at the call site with no local to annotate.
Neither scales to tens of sites.
Also tested and rejected: `cast(Q, qmd)` (type-checks but yields a dict typed as `Q`,
not a `Q`); `TypeIs` narrowing guards (make mypy and Pyright reject the splat too);
`cast(dict[str, str | bool | int], qmd)` (fails `mypy --strict`, and requires predicting
the union anyway).
## Environment
- Pyrefly 1.2.0 (PyPI)
- mypy 2.3.0
- Pyright 1.1.411
- Python 3.13
Happy to help refine or test patches.
Contributor guide
Research direction
Start with the repro.py cases and run the listed pyrefly, mypy, and pyright commands to confirm the disagreement. Read dict_items_infer_inner in pyrefly/lib/alt/expr.rs around lines 1650 and 1751-1820, then follow splat_kwargs in callable.rs at lines 1169 and 1421. Done means Any mappings accept both merge orders while the case_B and case_F controls still report errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- compilers, devtools
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100