The smell is easy to recognise: isinstance checks and None guards in every second function, because nobody knows what has already been validated. When validation lives everywhere, it is guaranteed nowhere — and every new function pays the fear tax again.
The fix is structural, not more checks. Pick the boundary — the place where outside data actually enters: job parameters, a config file, an API payload. Parse it there, once, into a typed model. Pydantic is built for exactly this job.
from pydantic import BaseModel, Field
class JobConfig(BaseModel):
catalog: str
target_schema: str
retries: int = Field(ge=0, le=5)
dry_run: bool = False
cfg = JobConfig.model_validate(raw_args) # invalid input dies HERE, loudly Past that line, the rules change. Inside the system, function signatures do the talking — a JobConfig cannot exist in an invalid state, so there is nothing left to re-check:
def build_target(cfg: JobConfig) -> str:
# no isinstance, no None checks, no re-validation:
# if this function runs, the config already parsed
return f"{cfg.catalog}.{cfg.target_schema}" Parse, don’t validate: make invalid states unrepresentable, then stop checking for them.
This is the same principle as the SQL-escaping I deleted in the trust-boundary piece, seen from the other side. Defenses belong at real boundaries. Inside the boundary, defensive re-checks are noise — and noise is worse than nothing, because it buries the checks that matter.
So: annotate everything, parse at the edge, and let downstream code trust its types. The honest limit — a boundary only works if everything passes through it. Data that leaves and comes back (a JSON round-trip, a table someone else writes) has crossed outside again, and earns one new parse at the door.