Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Type Annotations & Controller Overrides

Type Frappe controllers with DF field annotations, cast, TypedDict, and TYPE_CHECKING imports.

Updated
Tags
  • frappe
  • python
  • typing
  • controllers
Reading time
3 min

Enabling Type Annotations

In hooks.py:

export_python_type_annotations = True

This enables the DF.* type annotation system from frappe.types, allowing field-level type declarations on DocType controllers.

Field Type Annotations

Import DF from frappe.types and annotate fields on the controller class:

from frappe.types import DF

class MyDocType(Document):
    # Standard field types
    customer_name: DF.Data
    amount: DF.Currency
    is_active: DF.Check
    description: DF.TextEditor

    # Optional fields (custom or nullable)
    part_group: DF.Link | None
    pro_number: DF.Int | None
    surface_area: DF.Float | None
    charge: DF.Currency | None
    markup: DF.Percent | None

    # Child table fields
    items: DF.Table[BOMItem]
    operations: DF.Table[BOMOperation]

    # Table MultiSelect
    guardians: DF.TableMultiSelect[FamilyGuardian]

    # Select with literals
    status: DF.Literal["Open", "Completed", "Cancelled"]

Common DF.* Types

AnnotationFrappe Fieldtype
DF.DataData
DF.IntInt
DF.FloatFloat
DF.CurrencyCurrency
DF.PercentPercent
DF.CheckCheck
DF.LinkLink
DF.Table[ChildType]Table
DF.TableMultiSelect[ChildType]Table MultiSelect
DF.TextEditorText Editor
DF.Literal[...]Select

Controller Class Override Pattern

Simple Override (Custom Fields Only)

When you only need type annotations for custom fields:

from erpnext.stock.doctype.item.item import Item as BaseItem
from frappe.types import DF

class Item(BaseItem):
    """Custom Item with additional fields."""

    valuation_uom: DF.Link | None

Complex Override (Methods + Typed Children)

from typing import Any, cast
import frappe
from erpnext.manufacturing.doctype.bom.bom import BOM as BaseBOM
from frappe.types import DF

class BOM(BaseBOM):
    """Custom BOM override for app-specific functionality."""

    # Inline imports for child table types (avoids circular imports)
    from myapp.mymodule.doctype.bom_part_group.bom_part_group import BOMPartGroup
    part_group_measurements: DF.Table[BOMPartGroup]
    cadwork_guid: DF.Data

    # Override standard child table types for stronger typing
    from myapp.myapp.custom.bom_item import BOMItem
    items: DF.Table[BOMItem]

    # Transient attributes (not persisted to DB)
    _import_warnings: dict[str, list[dict[str, Any]]] | None = None

    def validate(self) -> None:
        super().validate()
        # Custom validation logic

    @frappe.whitelist()
    def custom_action(self) -> str:
        # Whitelisted method callable from client
        ...

Key Conventions

  1. Alias the base class to avoid name collision:

    from erpnext.stock.doctype.item.item import Item as BaseItem
  2. Custom fields use | None since they may not exist in the base schema:

    my_custom_field: DF.Float | None
  3. Inline imports for child table types prevent circular import issues:

    class BOM(BaseBOM):
        from myapp.custom.bom_item import BOMItem
        items: DF.Table[BOMItem]
  4. Transient attributes (not saved to DB) use class-level defaults:

    _cache: dict[str, Any] | None = None

Using cast for Type Safety

When frappe.get_doc() returns a generic Document, use cast to get type checking:

from typing import cast

doc = cast("Item", frappe.get_doc("Item", item_code))
# Now doc.valuation_uom, doc.uoms, etc. are typed

# Also useful for whitelisted endpoint return values
doc = cast("Item", frappe.get_doc("Item", item_code))
for row in doc.uoms:  # row is typed as UOMConversionDetail
    if row.uom == sales_uom:
        row.conversion_factor = flt(value)

TypedDict for API Contracts

Use TypedDict to type filter parameters, query results, and API responses:

from typing import TypedDict
from frappe.types import DF

class Filters(TypedDict, total=False):
    project: DF.Link | None
    sales_quote: DF.Link | None
    date_range: tuple[str, str]
    status: DF.Literal["Open", "Completed", "Cancelled"]

class QueryResult(TypedDict, total=False):
    id: str
    name: str
    quoted_hours: float
    actual_hours: float
    indent: int

def execute(filters: Filters | None = None) -> tuple[list[Any], list[QueryResult], None, Chart]:
    ...

Conditional Imports with TYPE_CHECKING

For imports only needed by type checkers (not at runtime):

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from myapp.myapp.custom.item import Item

def update_item(item_code: str) -> dict:
    doc = cast("Item", frappe.get_doc("Item", item_code))
    ...

Auto-Generated Type Blocks

Frappe can auto-generate type annotations for DocType controllers. These go in a guarded block:

class Family(Document):
    # begin: auto-generated types
    # This code is auto-generated. Do not modify anything in this block.
    from typing import TYPE_CHECKING
    if TYPE_CHECKING:
        from frappe.types import DF
        from my_app.my_module.doctype.family_guardian.family_guardian import FamilyGuardian

        address: DF.Link | None
        guardians: DF.TableMultiSelect[FamilyGuardian]
    # end: auto-generated types

Custom logic and additional annotations go outside this block.

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.