Skip to content

Strict callable boundaries

validate_call compiles Python annotations into a strict argument-and-return boundary for synchronous and asynchronous functions and methods. Use it when an application service, SDK entry point, callback implementation, or domain function should reject values that do not satisfy its declared Python contract.

from talea import validate_call


@validate_call
def transfer(amount: int, fee: int = 0) -> int:
    return amount - fee

The declaration happens once. Talea resolves every parameter and return annotation into canonical Schema truth, validates declared defaults, and emits a specialized Python wrapper. The warm path does not interpret inspect.Signature, rebuild dictionaries, walk Schema nodes, consult a registry, or acquire a lock.

The strict boundary

Callable validation handles values that Python code already possesses. It is therefore the same strict operation as Contract.validate, not an external conversion operation such as Contract.from_python.

@validate_call
def count(value: int) -> int:
    return value


count(1)       # accepted
count(True)    # ValidationError at ("value",)
count(1.0)     # ValidationError at ("value",)
count("1")     # ValidationError at ("value",)

Mappings do not construct Specs or dataclasses. A Representation annotation expects its internal Python value; it does not call load. Return validation does not call dump, to_dict, or to_json. Parse or convert at an explicit external boundary first, then pass the constructed value to the callable.

ResourcePolicy is intentionally absent. It governs hostile external input transport and traversal. A strict callable accepts trusted, already-existing Python values and retains normal Talea current-state checks for mutable Specs, dataclasses, TypedDicts, and containers.

Binding, validation, and application failures

Three failure domains remain distinct:

Stage Owner Failure
Python call shape CPython binding plain TypeError
parameter value Talea contract ValidationError at the parameter path
application function application original exception unchanged
return value Talea contract ValidationError beneath ("return",)

Missing required arguments, unexpected keywords, duplicate values, and too many positional arguments are rejected by the generated wrapper's real Python signature. Talea does not manufacture or parse CPython error text. A valid call shape is validated in declaration order and stops at the first invalid parameter. That fail-fast policy avoids success-path error-list allocation.

After all arguments pass, Talea invokes the original function exactly once. Argument failure invokes it zero times. Return failure occurs after the one application call. ValueError, TypeError, RuntimeError, and application exception subclasses raised by the function propagate unchanged; catch ValidationError before a broad TypeError because Talea validation errors are also type errors.

Successful strict validation preserves argument identity. Talea does not copy lists, dictionaries, dataclasses, Specs, or represented internal values merely to validate them. The application may mutate an argument after entry; Talea does not monitor it. Only an object returned from the function is checked again under the return contract.

Async execution and validation timing

Decorating async def emits a real coroutine function with the same Python signature. Argument validation runs inside that wrapper coroutine. The wrapper then creates and awaits the application coroutine exactly once, validates the awaited result against the declared return annotation, and returns the same result object:

@validate_call
async def authorize(payment_id: int, *, dry_run: bool = False) -> bool:
    return not dry_run


accepted: bool = await authorize(1)

Calling authorize("1") returns a normal coroutine object; no Talea value validation or application body executes until it is awaited. If it is never awaited, ordinary Python unawaited-coroutine behavior applies. Talea does not add an eager synchronous adapter or custom awaitable. CPython call-shape binding is different: a missing argument, positional-only violation, unexpected keyword, duplicate value, or missing keyword-only argument is a plain TypeError immediately when the wrapper is called.

The annotation on async def operation() -> Result describes the awaited Result, not the coroutine object. An invalid argument starts the application body zero times. A valid call creates, awaits, and executes the original coroutine once. An invalid awaited result fails afterward at ("return",). Application exceptions propagate unchanged; Talea does not reinterpret a ValueError, RuntimeError, domain exception, ExceptionGroup, or asyncio.CancelledError as validation failure.

Cancellation before the application body leaves it unstarted. Cancellation during application work reaches that coroutine normally, including its finally blocks, and is neither swallowed, wrapped, retried, nor followed by return validation. asyncio.timeout() and asyncio.wait_for() therefore compose normally, but Talea owns no timeout or retry policy.

The returned coroutine works directly with asyncio.create_task(), asyncio.gather(), and asyncio.TaskGroup. Each concurrent invocation has only local validation state. Gather and task groups retain their normal failure and ExceptionGroup behavior; Talea adds no batch semantics.

Defaults

Every declared default is validated while the decorator is applied. An invalid default prevents publication of the wrapper and reports the parameter location. Defaults whose canonical Schema proves immutable are then trusted when Python supplies that same omitted default; Talea does not repeat a redundant validation on every call. An explicitly supplied different value is validated normally.

Defaults with mutable current state are validated on every invocation, including omission. Mutating a list, mutable dataclass, mutable Spec graph, or TypedDict default into an invalid state therefore causes the next call to fail before application code runs. Talea does not alter Python's ordinary shared-mutable-default behavior.

Complete Python parameter binding

The generated wrapper preserves Python's complete synchronous and asynchronous parameter grammar: positional-only (/), positional-or-keyword, keyword-only (*), *args, scalar **kwargs, and **kwargs: Unpack[TypedDict]. These are all compiled from the same callable schema and signature emitter. There is no Signature.bind path or generic fallback for complex signatures.

@validate_call
def execute(
    account_id: int,
    /,
    quantity: int,
    *adjustments: int,
    dry_run: bool = False,
    timeout: float,
    **metadata: str,
) -> int:
    return quantity + sum(adjustments)

CPython binds account_id, quantity, dry_run, and timeout using that real signature. A positional-only name used as a keyword, a missing keyword-only parameter, a duplicate value, too many positional arguments, or an unexpected keyword where no **kwargs exists is a plain TypeError. Aliases never rename these Python call names.

For *adjustments: int, the annotation applies to each tuple member. Talea validates from index zero and fails at ("adjustments", index). For scalar **metadata: str, it applies to each value in Python's keyword dictionary and fails at ("metadata", actual_keyword). The compiler iterates those Python-created containers directly; it does not build normalized lists, tuples, argument maps, or validation copies.

Unpack[TypedDict]

**kwargs: Unpack[Options] has different semantics from named keyword-only parameters. Python's runtime signature contains **kwargs, so it accepts the keyword names syntactically. Talea then validates that collected dictionary as the canonical closed TypedDict structure:

from typing import NotRequired, TypedDict, Unpack


class Options(TypedDict):
    timeout: float
    trace_id: NotRequired[str]


@validate_call
def configure(**kwargs: Unpack[Options]) -> Options:
    return kwargs

A missing required timeout, an unknown key, or a wrong field value is a ValidationError under ("kwargs", key), not a binding TypeError. Talea validates Python's bound dictionary directly against the existing TypedDictSchema; this is strict Python structure validation, not external Mapping conversion. Alias metadata does not make an external alias a valid Python keyword. ReadOnly remains structural/static metadata and does not add runtime mutation enforcement. Concrete generic TypedDict specializations work; open generic forms retain the normal concrete-runtime requirement.

The location policy is stable across the synchronous and asynchronous surface:

Value Validation location
fixed parameter ("name", ...)
*args item ("args_name", index, ...)
scalar **kwargs value ("kwargs_name", actual_keyword, ...)
Unpack[TypedDict] field ("kwargs_name", field, ...)
method user argument the declared parameter path; receiver omitted
return value ("return", ...)

Defaults follow the same declaration-validation and immutable/mutable policy for positional-only, positional-or-keyword, and keyword-only parameters.

Supported annotations and metadata

The callable owner consumes the same canonical resolver and validation emitter as Contract and Spec. A supported parameter or return can therefore use primitives, constraints, concrete Specs and generics, standard-library dataclasses, TypedDicts, aliases, containers, unions, tagged unions, recursive schemas, and Representation where those annotations already have executable Talea semantics.

All user-value parameters and the return require annotations. Talea does not turn a missing annotation, Any, or object into an unchecked hole. Generic function declarations with unresolved runtime type parameters are rejected; static generic typing is not runtime specialization. typing.overload declarations remain static-only, while the concrete runtime implementation supplies the executable contract.

Alias changes external representation names, not Python keyword binding. A parameter named amount is still called as amount=..., regardless of an alias inside its annotation. Sensitive redacts Talea-owned parameter and return failures, including nested details. It cannot redact exceptions, logs, side effects, or messages produced by application function code.

Direct decoration can resolve module names and live function-local aliases, including deferred annotations. If a function-local name has already gone out of scope before validate_call(function) runs, Python has not retained enough truth to recover it; Talea rejects the declaration rather than inspecting arbitrary historical frames or creating a forward-reference registry.

Function identity and typing

The wrapper follows standard Python conventions. __name__, __qualname__, __doc__, __module__, and __annotations__ are copied, __wrapped__ points to the original function, and inspect.signature() reports the original public signature. Decorating the same Talea wrapper again is idempotent, so validation is not silently stacked.

The decorator is typed with ParamSpec and a return TypeVar. Static tooling therefore retains positional and keyword parameter names and types, defaults, and the return type:

@validate_call
def settle(amount: int, reference: str) -> bool:
    return True


accepted: bool = settle(amount=1, reference="invoice-1843")

ParamSpec preserves the static callable shape. It is not interpreted at runtime and does not infer concrete TypeVar substitutions for each invocation.

For async def, the original function's static return is already a coroutine whose awaited value is its declared return annotation. validate_call preserves that type unchanged: calling the wrapper remains awaitable and awaiting it produces the declared result. Talea does not wrap the return type in a second Coroutine.

Positional-only markers, keyword-only requirements, variadic item/value types, Unpack required and optional keys, method binding, and return types remain visible to static tooling. Runtime validation remains authoritative.

Methods and descriptors

Ordinary synchronous and asynchronous instance methods use Python's normal function descriptor. Talea waits until class ownership is established, classifies the first parameter as the receiver, compiles the method, and leaves the class with a normal generated function descriptor. Python supplies self exactly once. The receiver is binding infrastructure, so it does not require or receive Talea value validation; every other parameter and the return still do. This exemption cannot make an unannotated first parameter on an ordinary function valid.

Class and static methods require validate_call as the outer decorator:

class Service:
    @validate_call
    @classmethod
    async def create(cls, value: int) -> int:
        return value

    @validate_call
    @staticmethod
    async def normalize(value: int) -> int:
        return value
Form Policy
ordinary @validate_call instance method supported; self is receiver
@validate_call outside @classmethod supported; cls is receiver
@classmethod outside @validate_call rejected; Talea must be outermost
@validate_call outside @staticmethod supported; every parameter validates
@staticmethod outside @validate_call rejected; Talea must be outermost

This policy lets the descriptor itself establish class/static truth and avoids a permanent method adapter or registry. Inheritance, overrides, super(), and binding to an instance or subclass remain ordinary Python attribute resolution. Validated methods can call each other or recurse, and argument failure/body success/return failure retain the zero/one/one invocation rule.

inspect.signature(Class.method) reports the unbound instance signature, including self; inspect.signature(instance.method) reflects Python's bound signature without it. Classmethod access similarly removes cls, while a staticmethod has no receiver to remove. Wrapper names, qualified names, documentation, annotations, and __wrapped__ remain available.

Introspection

inspect_callable() is the single public projection of callable truth:

from talea.introspection import inspect_callable

info = inspect_callable(settle)
assert tuple(parameter.name for parameter in info.parameters) == (
    "amount",
    "reference",
)

CallableInfo and ParameterInfo are frozen and slotted. They expose the original inspect.Signature, ordered names and parameter kinds, canonical parameter and return schemas, required/default state, receiver flags, variadic semantics (items, values, or unpack_typed_dict), and callable kind (function, instance_method, class_method, or static_method). CallableInfo.is_async projects the decoration-time coroutine-function classification for the same contract; there is no async-specific inspection API or projection type. They do not expose generated source, compiled validators, the original callable, globals, locks, caches, or binding instructions. Standard __wrapped__ remains the way ordinary Python tooling reaches the original.

Callable introspection deliberately provides no JSON Schema or OpenAPI operation document. A callable is not an HTTP or RPC route; framework tooling may consume its parameter Schemas without making Talea own requests, responses, routing, or serialization.

Concurrency, reentrancy, and security

Compilation is eager at decoration time, so concurrent calls perform no lazy publication and acquire no Talea lock. A validated function can call another validated function or itself recursively; every invocation validates its own boundary, and no global validation state suppresses recursion.

Async wrappers follow the same rule across concurrent tasks, reentrant awaits, and recursive async calls. A validated async function may call a validated sync function and vice versa using ordinary Python execution and event-loop rules. Talea creates no task, ContextVar, coroutine registry, or shared per-call state.

Generated identifiers are compiler-owned. Parameter annotations and metadata are resolved into canonical schema and compiler state; defaults, location labels, runtime operations, and the original callable are bound as objects in the generated function namespace. Function metadata is copied only after compilation. None of these application-owned values is interpolated into source, so hostile reprs, quotes, newlines, Unicode, and unusual qualified names cannot become code. A wrapper naturally owns its contract and compiled globals for its own lifetime; there is no process-global callable registry or cache.

Talea owns binding shape, generated-source safety, argument and return enforcement, metadata, and Sensitive redaction. The application owns function CPU, memory, I/O, locks, side effects, mutation, recursion depth, exceptions, thread safety, tasks, cancellation policy, timeouts, and retries. Sensitive redacts Talea-owned async argument and return failures, but cannot sanitize an exception message emitted by application coroutine code.

Complete callable surface and remaining limits

The complete synchronous and asynchronous Python binding surface is supported: every fixed parameter kind, defaults, variadics, Unpack[TypedDict], ordinary methods, classmethods, staticmethods, strict argument and return validation, typing, cancellation transparency, and immutable introspection. Generators, async generators, and arbitrary callable objects are unsupported. Runtime generic-function specialization remains unsupported, and a deferred annotation name that was local to a scope already lost before decoration may be unrecoverable. Callable boundaries remain strict: there is no coercion, ResourcePolicy, function sandbox, framework/RPC adapter, timeout manager, or streaming execution mode.

Complete executable example

The payment-service example separates external construction from strict callable validation and demonstrates synchronous binding plus an async authorization service, constraints, nested Specs, keyword-only options, task composition, cancellation, invalid parameter and awaited return values, application exceptions, Sensitive redaction, __wrapped__, and immutable introspection.

"""Payment services exercising complete sync and async callable boundaries."""

import asyncio
import inspect
from dataclasses import dataclass
from typing import Annotated, Literal, NotRequired, TypedDict, Unpack

from talea import Ge, MinLength, Sensitive, Spec, ValidationError, validate_call
from talea.introspection import inspect_callable

type PositiveCents = Annotated[int, Ge(1)]
type Reference = Annotated[str, MinLength(8)]
type Secret = Annotated[str, Sensitive()]


class ExecutionOptions(TypedDict):
    """Strict keyword structure accepted by the execution operation."""

    timeout: float
    trace_id: NotRequired[str]
    authorization: NotRequired[Secret]


class PaymentReceipt(Spec):
    payment_id: str
    status: Literal["authorized", "simulated"]
    amount_cents: PositiveCents


class AuthorizationDeclined(RuntimeError):
    """Represent an application decision rather than a contract failure."""


@dataclass(frozen=True, slots=True)
class PaymentService:
    """Execute already-decoded, strictly typed payment commands."""

    gateway: str

    @validate_call
    @classmethod
    def connection_name(cls, gateway: str) -> str:
        """Build a class-qualified gateway name through a classmethod boundary."""

        return f"{cls.__name__}:{gateway}"

    @validate_call
    @staticmethod
    def normalize_reference(reference: Reference) -> Reference:
        """Normalize a reference without receiving an instance or class."""

        return reference.upper()

    @validate_call
    def execute(
        self,
        account_id: int,
        /,
        amount_cents: PositiveCents,
        *adjustments: int,
        dry_run: bool = False,
        **options: Unpack[ExecutionOptions],
    ) -> PaymentReceipt:
        """Execute a payment using every significant Python binding form."""

        del account_id, options
        settled = amount_cents + sum(adjustments)
        if settled <= 0:
            raise AuthorizationDeclined("non-positive settlement")
        return PaymentReceipt(
            payment_id=f"{self.gateway}-01JABCDE",
            status="simulated" if dry_run else "authorized",
            amount_cents=settled,
        )

    @validate_call
    def invalid_receipt(self, account_id: int, /) -> PaymentReceipt:
        """Model a dependency returning an invalid application value."""

        del account_id
        return "authorized"  # ty: ignore[invalid-return-type]


@dataclass(slots=True)
class AsyncAuthorizationService:
    """Authorize existing payment values through ordinary coroutine semantics."""

    entered: asyncio.Event
    cleanup_count: int = 0

    @validate_call
    async def authorize(
        self,
        receipt: PaymentReceipt,
        /,
        *,
        capture: bool = False,
        **options: Unpack[ExecutionOptions],
    ) -> PaymentReceipt:
        """Validate before I/O and validate the awaited service result."""

        del capture, options
        await asyncio.sleep(0)
        return receipt

    @validate_call
    async def decline(self, payment_id: str) -> PaymentReceipt:
        """Preserve an application-owned domain exception."""

        raise AuthorizationDeclined(payment_id)

    @validate_call
    async def invalid_receipt(self, payment_id: str) -> PaymentReceipt:
        """Model an async dependency returning invalid data."""

        del payment_id
        return "authorized"  # ty: ignore[invalid-return-type]

    @validate_call
    async def wait_for_settlement(self, receipt: PaymentReceipt) -> PaymentReceipt:
        """Expose normal cancellation and cleanup behavior."""

        self.entered.set()
        try:
            await asyncio.Event().wait()
        finally:
            self.cleanup_count += 1
        return receipt


assert PaymentService.connection_name("gateway") == "PaymentService:gateway"
service = PaymentService("gateway")
reference = PaymentService.normalize_reference("invoice-1843")
assert reference == "INVOICE-1843"

receipt = service.execute(
    7,
    1250,
    -50,
    dry_run=True,
    timeout=1.5,
    trace_id="trace-7",
)
assert receipt.status == "simulated"
assert receipt.amount_cents == 1200

# Python binds the positional-only identifier before Talea validates values.
try:
    service.execute(account_id=7, amount_cents=1250, timeout=1.5)  # ty: ignore[missing-argument]
except TypeError as error:
    assert type(error) is TypeError
else:
    raise AssertionError("a positional-only identifier passed by keyword must fail")

# A required Unpack key is structure validation, not a named-parameter bind.
try:
    service.execute(7, 1250)  # ty: ignore[missing-argument]
except ValidationError as error:
    assert error.location == ("options", "timeout")
else:
    raise AssertionError("a missing TypedDict option must fail")

# Valid call shape plus an invalid value is a Talea ValidationError.
try:
    service.execute(7, "1250", timeout=1.5)  # ty: ignore[invalid-argument-type]
except ValidationError as error:
    assert error.location == ("amount_cents",)
else:
    raise AssertionError("strict callable boundaries must not coerce values")

# Sensitive TypedDict fields redact Talea-owned evidence.
try:
    service.execute(7, 1250, timeout=1.5, authorization=123)  # ty: ignore[invalid-argument-type]
except ValidationError as error:
    assert error.location == ("options", "authorization")
    assert error.errors()[0]["input"] == "<redacted>"
else:
    raise AssertionError("sensitive keyword failure must be redacted")

# The application body still owns domain exceptions.
try:
    service.execute(7, 1, -2, timeout=1.5)
except AuthorizationDeclined as error:
    assert error.args == ("non-positive settlement",)
else:
    raise AssertionError("application rejection must propagate")

# Return validation runs after exactly one successful application call.
try:
    service.invalid_receipt(7)
except ValidationError as error:
    assert error.location == ("return",)
else:
    raise AssertionError("invalid returns must not escape")

unbound_signature = inspect.signature(PaymentService.execute)
bound_signature = inspect.signature(service.execute)
assert "self" in unbound_signature.parameters
assert "self" not in bound_signature.parameters
assert inspect.unwrap(PaymentService.execute).__name__ == "execute"

method_info = inspect_callable(service.execute)
assert method_info.callable_kind == "instance_method"
assert method_info.parameters[0].receiver is True
assert method_info.parameters[3].variadic_semantics == "items"
assert method_info.parameters[-1].variadic_semantics == "unpack_typed_dict"
assert inspect_callable(PaymentService.connection_name).callable_kind == "class_method"
assert inspect_callable(PaymentService.normalize_reference).callable_kind == "static_method"


async def exercise_async_boundaries() -> None:
    """Exercise awaiting, task composition, failures, and cancellation."""

    entered = asyncio.Event()
    authorizer = AsyncAuthorizationService(entered)
    async_info = inspect_callable(authorizer.authorize)
    assert async_info.is_async is True
    assert async_info.callable_kind == "instance_method"
    assert inspect.iscoroutinefunction(authorizer.authorize)
    assert inspect.unwrap(AsyncAuthorizationService.authorize).__name__ == "authorize"

    authorized, repeated = await asyncio.gather(
        authorizer.authorize(receipt, capture=True, timeout=1.5),
        authorizer.authorize(receipt, timeout=2.0, trace_id="trace-8"),
    )
    assert authorized is receipt
    assert repeated is receipt

    # Value validation begins when the normal wrapper coroutine is awaited.
    invalid = authorizer.authorize("not-a-receipt", timeout=1.5)  # ty: ignore[invalid-argument-type]
    try:
        await invalid
    except ValidationError as error:
        assert error.location == ("receipt",)
    else:
        raise AssertionError("an invalid async argument must fail before application I/O")

    try:
        await authorizer.invalid_receipt("payment-1")
    except ValidationError as error:
        assert error.location == ("return",)
    else:
        raise AssertionError("an invalid awaited result must not escape")

    try:
        await authorizer.decline("payment-1")
    except AuthorizationDeclined as error:
        assert error.args == ("payment-1",)
    else:
        raise AssertionError("application exceptions must propagate unchanged")

    task = asyncio.create_task(authorizer.wait_for_settlement(receipt))
    await entered.wait()
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass
    else:
        raise AssertionError("cancellation must remain visible to the caller")
    assert authorizer.cleanup_count == 1


asyncio.run(exercise_async_boundaries())

Performance evidence

task benchmark_callables compares direct calls, equivalent handwritten strict wrappers, Talea wrappers, and inspect.Signature.bind; async timings run repeated operations inside one already-running event loop. It measures one, two, and five primitive arguments, every fixed binding form, variadic 0/1/5/20 scaling, scalar keyword scaling, small and larger Unpack structures, instance/class/static methods, async task/gather/cancellation behavior, failures, cold complex compilation, success/failure allocations, retained memory, call counts, and bytecode. Sync wrappers contain no async branch; async wrappers contain a direct application call and await with no execution-mode dispatcher. Neither fixed warm path contains a generic binder, parameter loop, Schema walk, registry lookup, or lock. Callable support adds no execution path to Specs or Contracts that do not use the decorator.