Skip to content

Support strict mode type checking for mypy, pyright, and ty #325

Description

@leandrodamascena

Note: This analysis was performed with AI assistance (Claude Code). The AI ran mypy, pyright, and ty against the full codebase and all 200+ examples, categorized every error, identified root causes, and drafted the suggested fixes. I reviewed and validated the results. Using AI for this type of systematic audit across multiple type checkers saved significant time while ensuring nothing was missed.

Hey @Bear-03, I'll try to fix this.

Description

pydynox fails type checking under strict mode in all three major Python type checkers: mypy (--strict), pyright (basic mode), and ty. Users who enable strict type checking in their projects — which is increasingly common — see a wall of errors when importing and using pydynox.

mypy --strict: 102 errors across 12 files
pyright: 22 errors, 7 warnings across 5 files
ty: 0 errors (already fixed in #311)

Beyond the internal errors, the public API typing is the main pain point users report. Methods like get(), batch_get(), query(), and scan() return union types that force users to narrow manually, even in the most common usage patterns.

Part 1 — Public API typing issues (user-facing)

These are what users actually hit. No internal errors — these show up in user code.

1A. as_dict parameter doesn't narrow the return type

as_dict=False (default) should return M | None, but the type is always the full union. Affects 12 methods across Model:

# What the user writes (most common case):
user = User.sync_get(pk="USER#1")
# Type checker sees: User | dict[str, Any] | None  ← user expects User | None

user.name  # error: "dict[str, Any]" has no attribute "name"

# Same with batch_get:
users = User.sync_batch_get(keys)
# Type checker sees: list[User] | list[dict[str, Any]]  ← user expects list[User]

for u in users:
    print(u.name)  # error: "dict[str, Any]" has no attribute "name"

Methods affected (sync + async = x2):

Method Current return type Expected with as_dict=False Expected with as_dict=True
get / sync_get M | dict | None M | None dict | None
batch_get / sync_batch_get list[M] | list[dict] list[M] list[dict]
parallel_scan / sync_parallel_scan tuple[list[M] | list[dict], Metrics] tuple[list[M], Metrics] tuple[list[dict], Metrics]

Fix: Add @overload with Literal[True] / Literal[False] for as_dict:

from typing import Literal, overload

@overload
@classmethod
def sync_get(
    cls: type[M], *, as_dict: Literal[True], **keys: Any
) -> dict[str, Any] | None: ...

@overload
@classmethod
def sync_get(
    cls: type[M], *, as_dict: Literal[False] = ..., **keys: Any
) -> M | None: ...

def sync_get(
    cls: type[M], consistent_read: bool | None = None, as_dict: bool = False, **keys: Any
) -> M | dict[str, Any] | None:
    ...

This is not a breaking change — runtime behavior is identical. Only type inference improves.

1B. Query/scan iterators yield M | dict[str, Any] instead of M

Even without as_dict, the iterators always yield the union:

for order in Order.sync_query(partition_key="USER#1"):
    # Type: Order | dict[str, Any]  ← user expects Order
    print(order.total)  # error: "dict[str, Any]" has no attribute "total"

async for user in User.scan():
    # Type: User | dict[str, Any]  ← user expects User
    print(user.name)  # error

Affected classes: ModelQueryResult[M], AsyncModelQueryResult[M], ModelScanResult[M], AsyncModelScanResult[M]

Current:

def __next__(self) -> M | dict[str, Any]:  # always union
def first(self) -> M | dict[str, Any] | None:  # always union

Fix: Make result classes generic over the as_dict flag, or split into two result types, or use @overload on the Model methods that create them:

# Option A: overload at the Model level
@overload
@classmethod
def sync_query(cls: type[M], ..., as_dict: Literal[True], ...) -> ModelQueryResult[dict[str, Any]]: ...

@overload
@classmethod
def sync_query(cls: type[M], ..., as_dict: Literal[False] = ..., ...) -> ModelQueryResult[M]: ...

Then ModelQueryResult.__next__ returns M (or dict based on the generic param).

1C. from_dict returns M but the metaclass makes type checkers lose the type

# User writes:
user = User.from_dict({"pk": "USER#1", "name": "John"})
# Some type checkers see: ModelBase  ← user expects User

The from_dict classmethod is defined on ModelBase and returns M, but the metaclass pattern can confuse some type checkers about what cls resolves to.

1D. Attribute descriptors return T | None but users expect T for required fields

class User(Model):
    pk = StringAttribute(partition_key=True)  # always required, never None
    name = StringAttribute()                  # always required

user = User.sync_get(pk="USER#1")
if user:
    reveal_type(user.pk)   # str | None  ← user expects str (it's a key, always present)
    reveal_type(user.name) # str | None  ← user expects str (required field)

The __get__ descriptor always returns T | None regardless of whether the attribute is required. This causes downstream noise in every line that accesses an attribute.

Fix: This is the hardest one. Options:

  • Use @overload on __get__ to narrow based on required/optional (complex)
  • Accept T | None and document that users should use assert or if for narrowing
  • Add a py.typed marker and consider whether T alone is safe for required fields

1E. Client-level return_values overloads exist but Model-level doesn't expose them

The client put_item, delete_item, update_item already have proper @overload for return_values:

# Client level — already correct:
metrics = client.sync_put_item("users", item)  # → OperationMetrics
old = client.sync_put_item("users", item, return_values="ALL_OLD")  # → dict | None

But Model sync_save, sync_delete, sync_update don't expose return_values at all — they always return None. This is not a typing issue per se, but users who need the old item must drop to the client level.

Part 2 — Internal type checker errors

These are errors inside pydynox source that show up when running type checkers on the library itself. Users don't see them directly but they signal quality issues.

Category 1: Unused type: ignore comments (73 errors — mypy only)

After the _MixinBase typing fix in #311, the # type: ignore[attr-defined] comments in the mixin operation files became obsolete. mypy strict mode flags every one of them as [unused-ignore].

Files affected: _crud.py (38), _batch.py (12), _scan.py (10), _query.py (4), _table.py (8), _partiql.py (4)

# These are now unnecessary:
self._acquire_wcu(1.0)  # type: ignore[attr-defined]   # <-- unused
self._client.put_item(  # type: ignore[attr-defined]    # <-- unused

Fix: Remove all # type: ignore[attr-defined] comments from these 6 mixin files. The _MixinBase base class already provides proper type information.

Category 2: no-any-return from Rust client calls (18 errors — mypy only)

The Rust client (pydynox_core.DynamoDBClient) is a PyO3 extension. mypy sees all its return values as Any. When a Python method has a proper return type annotation but returns a value from the Rust client, mypy flags it.

Files affected: _crud.py (16), attributes/base.py (2), _results.py (1), testing/memory.py (1)

# _crud.py — mypy sees result["item"] as Any
async def get_item(...) -> dict[str, Any] | None:
    result = await self._client.get_item(...)  # _client is Any → result is Any
    return result["item"]  # error: Returning Any from function declared to return "dict[str, Any] | None"

Fix: Add explicit type annotations to intermediate variables or use cast():

async def get_item(...) -> dict[str, Any] | None:
    result = await self._client.get_item(...)
    item: dict[str, Any] | None = result["item"]
    return item

Category 3: reportOptionalMemberAccess on metrics (14 errors — pyright only)

In query.py, self._last_metrics is typed as OperationMetrics | None but accessed without a None guard in the scan/query result logging code.

File: query.py (lines 314-327, 625-637)

# self._last_metrics can be None
_log_operation("query", ..., self._last_metrics.duration_ms, ...)  # pyright: "duration_ms" is not a known attribute of "None"

Fix: Add a guard or assert before accessing metrics:

metrics = self._last_metrics
if metrics:
    _log_operation("query", ..., metrics.duration_ms, ...)

Category 4: has_template not on Attribute (6 errors — pyright only)

pyright doesn't see has_template as a valid attribute on Attribute[Any] because it's only present on template-capable subclasses. The hasattr() guard works at runtime but pyright doesn't narrow the type.

Files: _indexes.py, _model/_base.py, _model/_query.py, collection.py

if hasattr(attr, "has_template") and attr.has_template:  # pyright: Cannot access attribute "has_template"

Fix: Already partially fixed in #311 with cast(_TemplateAttr, attr). The remaining pyright errors are on the hasattr check line itself. Use a TypeGuard helper:

def _is_template_attr(attr: Attribute[Any]) -> TypeGuard[_TemplateAttr]:
    return hasattr(attr, "has_template") and attr.has_template

Category 5: Minor type issues (11 errors — mypy)

Error File Fix
var-annotated (2) _results.py:77,127 Add list[str] annotation to part_placeholders
no-redef (2) _helpers.py:240,344 Rename reused variable (valuesval_map, cond_namesmerged_names)
assignment — None to type (1) _model/_base.py:149 Type as type | None
assignment — LSI to GSI (1) _model/_base.py:165 Use a union type or separate variable
untyped-decorator (4) pytest_plugin.py Add @pytest.fixture type stub or cast
no-untyped-call (1) testing/memory.py:990 Add return type annotation

Category 6: Pydantic integration variance (2 errors pyright, 7 warnings pyright)

integrations/pydantic.py passes functions to add_dynamodb_methods where the TypeVar T gets widened to T | BaseModel after the issubclass check. Also, helper functions in _base.py use T only once in their signature.

Fix: Already partially addressed in #311 with cast(Any, ...). The warnings about single-use TypeVar can be fixed by using Any instead in the internal helper functions (they're not public API).

Breaking Changes

None. All fixes are additive:

  • @overload additions don't change runtime behavior — only type inference improves
  • Removing type: ignore comments has zero runtime effect
  • Adding cast() or type annotations has zero runtime effect
  • Result class generic parameter changes are type-level only

The only thing users might notice: methods that previously required assert isinstance(result, User) will now just work. Code that relies on the union type (e.g., isinstance checks) will continue to work.

Expected Behavior

  • mypy --strict, pyright, and ty should all report 0 errors on the pydynox source
  • User.sync_get(pk="...") should infer User | None, not User | dict | None
  • for u in User.sync_query(...) should infer u: User, not u: User | dict
  • User.sync_batch_get(keys) should infer list[User], not list[User] | list[dict]

Actual Behavior

102 mypy strict errors, 22 pyright errors, 7 pyright warnings. Public API return types are always the widest union regardless of parameters.

Environment

  • pydynox version: 1.1.0
  • Python version: 3.11+
  • mypy version: 1.19.1
  • pyright version: 1.1.408
  • ty version: 0.0.25

Suggested Fix

Phase 1 — Public API overloads (highest user impact)

Add @overload for as_dict parameter on all 12 affected Model methods (get, sync_get, batch_get, sync_batch_get, parallel_scan, sync_parallel_scan, query, sync_query, scan, sync_scan + result iterators). No runtime changes.

Phase 2 — Bulk cleanup (73 internal errors)

Remove all unused # type: ignore[attr-defined] comments from the 6 mixin files.

Phase 3 — Rust client return types (18 internal errors)

Add explicit type annotations to variables that receive values from the Rust client.

Phase 4 — pyright-specific fixes (22 errors)

  • Add TypeGuard helper for has_template checks
  • Add None guards before accessing self._last_metrics in query/scan result classes
  • Fix BaseModel | None in issubclass call

Phase 5 — Minor fixes (11 errors)

  • Annotate part_placeholders: list[str] in _results.py
  • Rename reused variables in _helpers.py
  • Fix type annotations in _model/_base.py metaclass
  • Add pytest.fixture typing in pytest_plugin.py

All changes are safe, mechanical, and testable. The existing test suite (753 unit + 730 integration + 200 examples) covers all affected code paths.

Phase 6 — Fix all 200+ examples for type correctness (565 pyright errors)

The docs/examples/ directory has 565 pyright errors across 200+ example files. These examples are what users copy — they must be 100% type-clean.

Error breakdown by root cause:

Root Cause Count % Fix
as_dict union: dict has no attribute X 263 47% Fixed by Phase 1 overloads — examples won't need changes
T|None: accessing attr on possibly None 106 19% Add None guards / assertions in examples
Async method not awaited 47 8% Add await or switch to sync_ methods
Other attribute access issues 41 7% Various — S3, inheritance, etc.
Missing imports (third-party) 33 6% Add # pyright: reportMissingImports=false or stub
Argument type mismatch 29 5% Fix example code to match signatures
Assignment / call / operator issues 46 8% Various fixes

Errors by directory (top 10):

Directory Errors Notes
agentic/ 138 Third-party imports + union types
query/ 51 Union types from query results
models/ 50 Attribute access on T|None
indexes/ 31 Template/union type issues
optimistic_locking/ 30 T|None attribute access
conditions/ 24 Union types
testing/ 29 Memory backend typing
atomic/ 26 T|None attribute access
s3/ 20 S3Value typing
smart_save/ 19 T|None attribute access

Key insight: 47% of example errors (263) will be automatically fixed by Phase 1 (@overload for as_dict). After that, the remaining ~300 errors are mainly examples not handling None properly — which is both a code fix in the examples AND an opportunity to teach good patterns.

Fix approach:

  1. After Phase 1, re-run pyright on examples — expect ~263 errors to disappear
  2. Fix async examples: add await or switch to sync_ variants
  3. Add proper None handling patterns (assert, if-guard, or or default)
  4. For third-party imports (smolagents, pydantic_ai, strands), add # pyright: reportMissingImports=false at file top
  5. Fix type_checking/crud_types.py — currently broken (uses async methods without await, wrong type annotations)

Phase 7 — Rewrite type-checking documentation

The current docs/guides/type-checking.md has several problems:

  1. Recommends workarounds instead of proper typing: tells users to use isinstance and cast() for union types — these are workarounds for the missing @overload that Phase 1 fixes
  2. ty status outdated: says "Partial" but ty now passes with 0 errors after [BUG] batch_get silently ignores consistent_read parameter #311
  3. Example file broken: crud_types.py (included via --8<--) has 8 pyright errors — async methods without await, wrong type annotations
  4. Missing best practices: no mention of strict mode, no pyright config example, no section on reveal_type() debugging
  5. No mention of overloaded return types: after Phase 1, the doc should explain that as_dict=False (default) gives you User | None and as_dict=True gives you dict | None

Rewrite plan:

  • Update type checker matrix (ty → ✅ Tested, all three pass)
  • Remove workaround sections (isinstance/cast for union types) — replace with "it just works"
  • Add strict mode configuration examples for mypy, pyright, and ty
  • Rewrite crud_types.py example to show proper typed usage
  • Add section on reveal_type() for debugging type issues
  • Add section on attribute T | None and when to use assertions
  • Add pyright pyrightconfig.json example
  • Add mypy mypy.ini strict config example

Expected Behavior (updated)

After all 7 phases:

  • mypy --strict, pyright, and ty should all report 0 errors on the pydynox source
  • pyright should report 0 errors on all 200+ example files
  • User.sync_get(pk="...") should infer User | None, not User | dict | None
  • for u in User.sync_query(...) should infer u: User, not u: User | dict
  • User.sync_batch_get(keys) should infer list[User], not list[User] | list[dict]
  • The type-checking guide should demonstrate zero-workaround typing
  • All examples should serve as copy-paste-ready code with clean types

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions