Skip to content

Basic Usage

This guide covers the fundamental concepts and basic usage patterns of Duckdantic.

Defining Traits

A trait is a specification of what fields an object should have:

from duckdantic import TraitSpec, FieldSpec

# Define a simple trait
PersonTrait = TraitSpec(
    name="Person",
    fields=(
        FieldSpec("name", str, required=True),
        FieldSpec("age", int, required=True),
    )
)

Field Specifications

Each field in a trait is defined by a FieldSpec:

FieldSpec(
    name="email",          # Field name
    typ=str,              # Expected type
    required=True,        # Is it required?
    accept_alias=True,    # Accept field aliases?
    check_types=True,     # Enforce type checking?
)

Optional Fields

Not all fields need to be required:

ProfileTrait = TraitSpec(
    name="Profile",
    fields=(
        FieldSpec("username", str, required=True),   # Required
        FieldSpec("bio", str, required=False),       # Optional
        FieldSpec("website", str, required=False),   # Optional
    )
)

Checking Satisfaction

Use satisfies() to check if an object matches a trait:

from duckdantic import satisfies

# Check different object types
person_dict = {"name": "Alice", "age": 30}
assert satisfies(person_dict, PersonTrait)

class Employee:
    def __init__(self, name: str, age: int, dept: str):
        self.name = name
        self.age = age
        self.dept = dept

emp = Employee("Bob", 25, "Engineering")
assert satisfies(emp, PersonTrait)  # Extra fields are OK

Getting Detailed Feedback

Use explain() to understand why validation fails:

from duckdantic import explain

incomplete = {"name": "Charlie"}  # Missing age

result = explain(incomplete, PersonTrait)
print(result)
# {
#     'ok': False,
#     'missing': ['age'],
#     'type_conflicts': [],
#     'reasons': ["missing required field 'age'"]
# }

Working with Types

Basic Types

Duckdantic supports all Python types:

ContactTrait = TraitSpec(
    name="Contact",
    fields=(
        FieldSpec("name", str),
        FieldSpec("age", int),
        FieldSpec("height", float),
        FieldSpec("active", bool),
        FieldSpec("tags", list),
        FieldSpec("metadata", dict),
    )
)

Generic Types

Use typing module for more specific types:

from typing import List, Dict, Optional

DetailedTrait = TraitSpec(
    name="Detailed",
    fields=(
        FieldSpec("tags", List[str]),
        FieldSpec("scores", Dict[str, float]),
        FieldSpec("nickname", Optional[str]),
    )
)

Numeric Widening

By default, int satisfies float requirements:

MeasurementTrait = TraitSpec(
    name="Measurement",
    fields=(FieldSpec("value", float, required=True),)
)

# Int satisfies float
int_measurement = {"value": 42}
assert satisfies(int_measurement, MeasurementTrait)

Working with Different Object Types

Dictionaries

The simplest case - dictionaries work directly:

user_dict = {
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com"
}

assert satisfies(user_dict, UserTrait)

Classes with Attributes

Any object with attributes works:

class Customer:
    def __init__(self, id: int, name: str):
        self.id = id
        self.name = name

customer = Customer(2, "Bob")
assert satisfies(customer, UserTrait)

Dataclasses

from dataclasses import dataclass

@dataclass
class Product:
    id: int
    name: str
    price: float

product = Product(1, "Widget", 9.99)
assert satisfies(product, ProductTrait)

Pydantic Models

from pydantic import BaseModel

class Order(BaseModel):
    id: int
    customer: str
    total: float

order = Order(id=1, customer="Alice", total=99.99)
assert satisfies(order, OrderTrait)

TypedDict

from typing import TypedDict

class PersonDict(TypedDict):
    name: str
    age: int

person: PersonDict = {"name": "Charlie", "age": 35}
assert satisfies(person, PersonTrait)

Type Policies

Control how types are compared:

from duckdantic import TypeCompatPolicy, satisfies

# Strict policy - no type coercion
strict_policy = TypeCompatPolicy(
    allow_numeric_widening=False,
    allow_optional_widening=False
)

# This would fail with strict policy
int_value = {"value": 42}
float_trait = TraitSpec(
    name="FloatValue",
    fields=(FieldSpec("value", float),)
)

assert satisfies(int_value, float_trait)  # True (default)
assert not satisfies(int_value, float_trait, strict_policy)  # False

Trait Composition

Combine traits using set operations:

from duckdantic import union, intersect, minus

# Base traits
UserTrait = TraitSpec(
    name="User",
    fields=(
        FieldSpec("id", int),
        FieldSpec("name", str),
    )
)

ContactTrait = TraitSpec(
    name="Contact",
    fields=(
        FieldSpec("email", str),
        FieldSpec("phone", str),
    )
)

# Union - accepts objects that satisfy either trait
FlexibleTrait = union(UserTrait, ContactTrait)

# Intersection - requires fields from both traits
CompleteUserTrait = intersect(UserTrait, ContactTrait)

# Minus - remove specific fields
PublicUserTrait = minus(UserTrait, ["internal_id"])

Common Patterns

API Response Validation

SuccessResponseTrait = TraitSpec(
    name="SuccessResponse",
    fields=(
        FieldSpec("status", str),
        FieldSpec("data", dict),
        FieldSpec("timestamp", str),
    )
)

def handle_response(response: dict):
    if not satisfies(response, SuccessResponseTrait):
        raise ValueError("Invalid response format")

    # Safe to access fields
    return response["data"]

Configuration Validation

DatabaseConfigTrait = TraitSpec(
    name="DatabaseConfig",
    fields=(
        FieldSpec("host", str, required=True),
        FieldSpec("port", int, required=True),
        FieldSpec("database", str, required=True),
        FieldSpec("username", str, required=True),
        FieldSpec("password", str, required=True),
        FieldSpec("ssl", bool, required=False),
    )
)

def connect_to_database(config: dict):
    if not satisfies(config, DatabaseConfigTrait):
        result = explain(config, DatabaseConfigTrait)
        raise ValueError(f"Invalid config: missing {result['missing']}")

    # Config is valid
    return create_connection(**config)

Plugin Validation

PluginTrait = TraitSpec(
    name="Plugin",
    fields=(
        FieldSpec("name", str),
        FieldSpec("version", str),
        FieldSpec("initialize", callable),
        FieldSpec("execute", callable),
    )
)

def load_plugin(plugin_class):
    if not satisfies(plugin_class, PluginTrait):
        raise TypeError("Invalid plugin interface")

    return plugin_class()

Next Steps