Skip to content

Pydantic Boundary Models

model Foo: emits a Pydantic BaseModel(extra="forbid"). Use it for data crossing a trust boundary — HTTP requests, JSON files, env-derived structures, message-queue payloads — where you want runtime validation in addition to compile-time types.

A worked example

model UserInput:
email: str
name: str = "anon"
age: int? = None

What you get

  • Compile-time type-checking — Typhon’s static rules apply.
  • Runtime validation — Pydantic checks types and rejects on construction.
  • extra="forbid" — unexpected fields raise ValidationError, not silently drop.
  • @dataclass-style construction — UserInput(email="a@b").
  • .model_dump() / .model_validate() — for JSON serialisation. Since v0.10.0 these (plus .model_dump_json()) also work under the in-process VM (tyc run) for flat model classes: model_validate(mapping) constructs an instance from a dict, model_dump() returns the fields in declaration order, and model_dump_json() the JSON form. Nested-model validation is not type-directed in the VM yet — deeply-nested models still need --compile.

When to use model vs class

class (dataclass)model (Pydantic)
Internal typesData crossing trust boundaries
Maximum performanceRuntime validation
No extra constraintsField(min_length=1, ...) validators

Mix freely in one project.

With Pydantic field validators

from pydantic import Field
model UserInput:
email: str = Field(..., pattern=r"^[^@]+@[^@]+$")
age: int = Field(..., ge=0, le=150)

Field(...) is Pydantic — the checker treats it as Pydantic-equivalent and the runtime validator enforces. Typhon does not introduce its own validator surface; Pydantic’s is mature.

In FastAPI / similar frameworks

from fastapi import FastAPI
app = FastAPI()
model CreateUser:
email: str
name: str
model UserResponse:
id: int
email: str
name: str
@app.post("/users", response_model=UserResponse)
async def create_user(input: CreateUser) -> UserResponse:
...

model plays exactly as Pydantic does — FastAPI sees the BaseModel and binds it to the request body.

Why extra="forbid" is the default

Pydantic’s stock setting is extra="ignore", which silently drops unexpected fields. Typhon’s safety pitch forbids quiet failures: if a client sends {"email": "a@b", "extra": "boom"}, you want a 422 response, not a silent drop.

If you genuinely need permissive mode, the configurable [emit] model-extra knob is roadmapped. There is no Typhon syntax for overriding the per-class model_config today — edit the emitted .py by hand, write the model as a plain Pydantic class in a .py file alongside your .ty sources (the build pipeline copies plain .py through untouched), or wait for the config knob to land.

Frozen models

The frozen modifier currently only applies to class (see Classes & Models) — model Foo frozen: is not parsed today. To make a Pydantic model immutable at runtime, drop into a plain .py file alongside your .ty sources (the build pipeline copies plain .py through):

src/models_immutable.py
from pydantic import BaseModel, ConfigDict
class Coordinate(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
x: float
y: float

Pydantic’s frozen=True blocks field reassignment (faux immutability — nested mutables can still be mutated). A model modifier for frozen is on the roadmap.

Where next