[ty] Validate writes to TypedDict keys by sharkdp · Pull Request #19782 · astral-sh/ruff (original) (raw)

@sharkdp added the ty

Multi-file analysis & type inference

label

Aug 6, 2025

sharkdp

sharkdp

@sharkdp

@sharkdp sharkdp marked this pull request as ready for review

August 6, 2025 13:37

@carljm carljm deleted the david/typeddict-setitem branch

August 6, 2025 22:19

dcreager added a commit that referenced this pull request

Aug 7, 2025

@dcreager

sharkdp added a commit that referenced this pull request

Aug 25, 2025

@PrettyWood @sharkdp

Summary

Implement validation for TypedDict constructor calls and dictionary literal assignments, including support for total=False and proper field management. Also add support for Required and NotRequired type qualifiers in TypedDict classes, along with proper inheritance behavior and the total= parameter. Support both constructor calls and dict literal syntax

part of astral-sh/ty#154

Basic Required Field Validation

class Person(TypedDict):
    name: str
    age: int | None

# Error: Missing required field 'name' in TypedDict `Person` constructor
incomplete = Person(age=25)

# Error: Invalid argument to key "name" with declared type `str` on TypedDict `Person`
wrong_type = Person(name=123, age=25)

# Error: Invalid key access on TypedDict `Person`: Unknown key "extra"
extra_field = Person(name="Bob", age=25, extra=True)

Screenshot 2025-08-07 at 17 59 22

Support for total=False

class OptionalPerson(TypedDict, total=False):
    name: str
    age: int | None

# All valid - all fields are optional with total=False
charlie = OptionalPerson()
david = OptionalPerson(name="David")
emily = OptionalPerson(age=30)
frank = OptionalPerson(name="Frank", age=25)

# But type validation and extra fields still apply
invalid_type = OptionalPerson(name=123)  # Error: Invalid argument type
invalid_extra = OptionalPerson(extra=True)  # Error: Invalid key access

Dictionary Literal Validation

# Type checking works for both constructors and dict literals
person: Person = {"name": "Alice", "age": 30}

reveal_type(person["name"])  # revealed: str
reveal_type(person["age"])   # revealed: int | None

# Error: Invalid key access on TypedDict `Person`: Unknown key "non_existing"
reveal_type(person["non_existing"])  # revealed: Unknown

Required, NotRequired, total

from typing import TypedDict
from typing_extensions import Required, NotRequired

class PartialUser(TypedDict, total=False):
    name: Required[str]      # Required despite total=False
    age: int                 # Optional due to total=False
    email: NotRequired[str]  # Explicitly optional (redundant)

class User(TypedDict):
    name: Required[str]      # Explicitly required (redundant)
    age: int                 # Required due to total=True
    bio: NotRequired[str]    # Optional despite total=True

# Valid constructions
partial = PartialUser(name="Alice")  # name required, age optional
full = User(name="Bob", age=25)      # name and age required, bio optional

# Inheritance maintains original field requirements
class Employee(PartialUser):
    department: str                  # Required (new field)
    # name: still Required (inherited)
    # age: still optional (inherited)

emp = Employee(name="Charlie", department="Engineering")  # ✅
Employee(department="Engineering")  # ❌
e: Employee = {"age": 1}  # ❌

Screenshot 2025-08-11 at 22 02 57

Implementation

The implementation reuses existing validation logic done in #19782

ℹ️ Why I did NOT synthesize an __init__ for TypedDict:

TypedDict inherits dict.__init__(self, *args, **kwargs) that accepts all arguments. The type resolution system finds this inherited signature before looking for synthesized members. So own_synthesized_member() is never called because a signature already exists.

To force synthesis, you'd have to override Python’s inheritance mechanism, which would break compatibility with the existing ecosystem.

This is why I went with ad-hoc validation. IMO it's the only viable approach that respects Python’s inheritance semantics while providing the required validation.

Refacto of Field

Before:

struct Field<'db> {
    declared_ty: Type<'db>,
    default_ty: Option<Type<'db>>,     // NamedTuple and dataclass only
    init_only: bool,                   // dataclass only  
    init: bool,                        // dataclass only
    is_required: Option<bool>,         // TypedDict only
}

After:

struct Field<'db> {
    declared_ty: Type<'db>,
    kind: FieldKind<'db>,
}

enum FieldKind<'db> {
    NamedTuple { default_ty: Option<Type<'db>> },
    Dataclass { default_ty: Option<Type<'db>>, init_only: bool, init: bool },
    TypedDict { is_required: bool },
}

Test Plan

Updated Markdown tests


Co-authored-by: David Peter mail@david-peter.de

second-ed pushed a commit to second-ed/ruff that referenced this pull request

Sep 9, 2025

Summary

Implement validation for TypedDict constructor calls and dictionary literal assignments, including support for total=False and proper field management. Also add support for Required and NotRequired type qualifiers in TypedDict classes, along with proper inheritance behavior and the total= parameter. Support both constructor calls and dict literal syntax

part of astral-sh/ty#154

Basic Required Field Validation

class Person(TypedDict):
    name: str
    age: int | None

# Error: Missing required field 'name' in TypedDict `Person` constructor
incomplete = Person(age=25)

# Error: Invalid argument to key "name" with declared type `str` on TypedDict `Person`
wrong_type = Person(name=123, age=25)

# Error: Invalid key access on TypedDict `Person`: Unknown key "extra"
extra_field = Person(name="Bob", age=25, extra=True)

Screenshot 2025-08-07 at 17 59 22

Support for total=False

class OptionalPerson(TypedDict, total=False):
    name: str
    age: int | None

# All valid - all fields are optional with total=False
charlie = OptionalPerson()
david = OptionalPerson(name="David")
emily = OptionalPerson(age=30)
frank = OptionalPerson(name="Frank", age=25)

# But type validation and extra fields still apply
invalid_type = OptionalPerson(name=123)  # Error: Invalid argument type
invalid_extra = OptionalPerson(extra=True)  # Error: Invalid key access

Dictionary Literal Validation

# Type checking works for both constructors and dict literals
person: Person = {"name": "Alice", "age": 30}

reveal_type(person["name"])  # revealed: str
reveal_type(person["age"])   # revealed: int | None

# Error: Invalid key access on TypedDict `Person`: Unknown key "non_existing"
reveal_type(person["non_existing"])  # revealed: Unknown

Required, NotRequired, total

from typing import TypedDict
from typing_extensions import Required, NotRequired

class PartialUser(TypedDict, total=False):
    name: Required[str]      # Required despite total=False
    age: int                 # Optional due to total=False
    email: NotRequired[str]  # Explicitly optional (redundant)

class User(TypedDict):
    name: Required[str]      # Explicitly required (redundant)
    age: int                 # Required due to total=True
    bio: NotRequired[str]    # Optional despite total=True

# Valid constructions
partial = PartialUser(name="Alice")  # name required, age optional
full = User(name="Bob", age=25)      # name and age required, bio optional

# Inheritance maintains original field requirements
class Employee(PartialUser):
    department: str                  # Required (new field)
    # name: still Required (inherited)
    # age: still optional (inherited)

emp = Employee(name="Charlie", department="Engineering")  # ✅
Employee(department="Engineering")  # ❌
e: Employee = {"age": 1}  # ❌

Screenshot 2025-08-11 at 22 02 57

Implementation

The implementation reuses existing validation logic done in astral-sh#19782

ℹ️ Why I did NOT synthesize an __init__ for TypedDict:

TypedDict inherits dict.__init__(self, *args, **kwargs) that accepts all arguments. The type resolution system finds this inherited signature before looking for synthesized members. So own_synthesized_member() is never called because a signature already exists.

To force synthesis, you'd have to override Python’s inheritance mechanism, which would break compatibility with the existing ecosystem.

This is why I went with ad-hoc validation. IMO it's the only viable approach that respects Python’s inheritance semantics while providing the required validation.

Refacto of Field

Before:

struct Field<'db> {
    declared_ty: Type<'db>,
    default_ty: Option<Type<'db>>,     // NamedTuple and dataclass only
    init_only: bool,                   // dataclass only  
    init: bool,                        // dataclass only
    is_required: Option<bool>,         // TypedDict only
}

After:

struct Field<'db> {
    declared_ty: Type<'db>,
    kind: FieldKind<'db>,
}

enum FieldKind<'db> {
    NamedTuple { default_ty: Option<Type<'db>> },
    Dataclass { default_ty: Option<Type<'db>>, init_only: bool, init: bool },
    TypedDict { is_required: bool },
}

Test Plan

Updated Markdown tests


Co-authored-by: David Peter mail@david-peter.de

KotlinIsland pushed a commit to KotlinIsland/basedpython that referenced this pull request

May 1, 2026

@PrettyWood @sharkdp

Summary

Implement validation for TypedDict constructor calls and dictionary literal assignments, including support for total=False and proper field management. Also add support for Required and NotRequired type qualifiers in TypedDict classes, along with proper inheritance behavior and the total= parameter. Support both constructor calls and dict literal syntax

part of astral-sh/ty#154

Basic Required Field Validation

class Person(TypedDict):
    name: str
    age: int | None

# Error: Missing required field 'name' in TypedDict `Person` constructor
incomplete = Person(age=25)

# Error: Invalid argument to key "name" with declared type `str` on TypedDict `Person`
wrong_type = Person(name=123, age=25)

# Error: Invalid key access on TypedDict `Person`: Unknown key "extra"
extra_field = Person(name="Bob", age=25, extra=True)

Screenshot 2025-08-07 at 17 59 22

Support for total=False

class OptionalPerson(TypedDict, total=False):
    name: str
    age: int | None

# All valid - all fields are optional with total=False
charlie = OptionalPerson()
david = OptionalPerson(name="David")
emily = OptionalPerson(age=30)
frank = OptionalPerson(name="Frank", age=25)

# But type validation and extra fields still apply
invalid_type = OptionalPerson(name=123)  # Error: Invalid argument type
invalid_extra = OptionalPerson(extra=True)  # Error: Invalid key access

Dictionary Literal Validation

# Type checking works for both constructors and dict literals
person: Person = {"name": "Alice", "age": 30}

reveal_type(person["name"])  # revealed: str
reveal_type(person["age"])   # revealed: int | None

# Error: Invalid key access on TypedDict `Person`: Unknown key "non_existing"
reveal_type(person["non_existing"])  # revealed: Unknown

Required, NotRequired, total

from typing import TypedDict
from typing_extensions import Required, NotRequired

class PartialUser(TypedDict, total=False):
    name: Required[str]      # Required despite total=False
    age: int                 # Optional due to total=False
    email: NotRequired[str]  # Explicitly optional (redundant)

class User(TypedDict):
    name: Required[str]      # Explicitly required (redundant)
    age: int                 # Required due to total=True
    bio: NotRequired[str]    # Optional despite total=True

# Valid constructions
partial = PartialUser(name="Alice")  # name required, age optional
full = User(name="Bob", age=25)      # name and age required, bio optional

# Inheritance maintains original field requirements
class Employee(PartialUser):
    department: str                  # Required (new field)
    # name: still Required (inherited)
    # age: still optional (inherited)

emp = Employee(name="Charlie", department="Engineering")  # ✅
Employee(department="Engineering")  # ❌
e: Employee = {"age": 1}  # ❌

Screenshot 2025-08-11 at 22 02 57

Implementation

The implementation reuses existing validation logic done in astral-sh/ruff#19782

ℹ️ Why I did NOT synthesize an __init__ for TypedDict:

TypedDict inherits dict.__init__(self, *args, **kwargs) that accepts all arguments. The type resolution system finds this inherited signature before looking for synthesized members. So own_synthesized_member() is never called because a signature already exists.

To force synthesis, you'd have to override Python’s inheritance mechanism, which would break compatibility with the existing ecosystem.

This is why I went with ad-hoc validation. IMO it's the only viable approach that respects Python’s inheritance semantics while providing the required validation.

Refacto of Field

Before:

struct Field<'db> {
    declared_ty: Type<'db>,
    default_ty: Option<Type<'db>>,     // NamedTuple and dataclass only
    init_only: bool,                   // dataclass only  
    init: bool,                        // dataclass only
    is_required: Option<bool>,         // TypedDict only
}

After:

struct Field<'db> {
    declared_ty: Type<'db>,
    kind: FieldKind<'db>,
}

enum FieldKind<'db> {
    NamedTuple { default_ty: Option<Type<'db>> },
    Dataclass { default_ty: Option<Type<'db>>, init_only: bool, init: bool },
    TypedDict { is_required: bool },
}

Test Plan

Updated Markdown tests


Co-authored-by: David Peter mail@david-peter.de

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.Learn more about bidirectional Unicode characters

[ Show hidden characters]({{ revealButtonHref }})