Skip to content
Sarj Code Standards

no-custom-control-flow-exception

Return coded typed outcomes for expected domain failures instead of exception-only contracts.

Why

A marker, fixed-message or payload-bearing custom exception creates another control-flow type without declaring failure data in the function's return contract. Expected domain outcomes can often use tagged result variants that callers handle explicitly. A caller may need error identifiers for an access check before reporting a rejected record; preserve that handling context. Exceptional failures and framework rejection still need exceptions.

Fix

Review direct raises of this exception. If the condition is an expected domain outcome, reuse an established Result[T, E] or a generic Success[T] | Error[E] union with a shared Literal discriminator, a stable domain error code and the handling context. Update callers to handle, propagate or translate each relevant variant; use the native type checker's assert_never for exhaustiveness. Extend an existing outcome union rather than wrapping it again. For a serializable contract, wrap the successful payload too; avoid a raw scalar | Error union. Use the project's established codec and validate incoming tags and payloads; Pydantic models with an Annotated Field(discriminator=...) union and TypeAdapter are one option. Format the message at the consumer. Preserve true exceptional failures, validation rejection, transport/framework protocols, cancellation, unwinding and useful cause context. A class name or a raise does not prove the condition is expected.

Examples

Before — flagged A guarded rejection hides typed context in its exception contract
app/content.py
class Denied(Exception):
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Denied {name}")
def load(name: str, allowed: bool) -> str:
if not allowed:
raise Denied(name)
return name
After — preferred A typed outcome retains the code and formatting context
app/content.py
from typing import Literal, assert_never
from pydantic import BaseModel, TypeAdapter
from app.outcomes import Error, Result, Success
class Denied(BaseModel):
code: Literal["denied"] = "denied"
name: str
def load(name: str, allowed: bool) -> Result[str, Denied]:
return (
Success[str](value=name) if allowed else Error[Denied](error=Denied(name=name))
)
def format_result(result: Result[str, Denied]) -> str:
if result.kind == "success":
return result.value
if result.kind == "error":
return f"Denied {result.error.name}"
assert_never(result)
codec = TypeAdapter[Result[str, Denied]](Result[str, Denied])
def decode_result(data: str) -> Result[str, Denied]:
return codec.validate_json(data, strict=True)
app/outcomes.py
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class Success[T](BaseModel):
kind: Literal["success"] = "success"
value: T
class Error[E](BaseModel):
kind: Literal["error"] = "error"
error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]
Before — flagged A local message variable still hides a typed rejection
app/service.py
class Denied(Exception):
def __init__(self, detail: str) -> None:
self.detail = detail
super().__init__(detail)
def save(name: str, allowed: bool) -> str:
if not allowed:
message = f"Denied {name}"
raise Denied(message)
return name
After — preferred Both success and rejection carry a stable discriminator
app/outcomes.py
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class Success[T](BaseModel):
kind: Literal["success"] = "success"
value: T
class Error[E](BaseModel):
kind: Literal["error"] = "error"
error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]
app/service.py
from typing import Literal, assert_never
from pydantic import BaseModel, TypeAdapter
from app.outcomes import Error, Result, Success
class Denied(BaseModel):
code: Literal["denied"] = "denied"
name: str
def save(name: str, allowed: bool) -> Result[str, Denied]:
return (
Success[str](value=name) if allowed else Error[Denied](error=Denied(name=name))
)
def format_result(result: Result[str, Denied]) -> str:
if result.kind == "success":
return result.value
if result.kind == "error":
return f"Denied {result.error.name}"
assert_never(result)
codec = TypeAdapter[Result[str, Denied]](Result[str, Denied])
def decode_result(data: str) -> Result[str, Denied]:
return codec.validate_json(data, strict=True)
Before — flagged A local marker hides a domain outcome outside the return contract
app/content.py
class UnsupportedContent(ValueError):
pass
def read_content(known: bool) -> str:
if not known:
raise UnsupportedContent("Unsupported content")
return "content"
After — preferred The return contract represents the expected unsupported outcome
app/content.py
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class Content:
value: str
kind: Literal["content"] = "content"
@dataclass(frozen=True)
class Unsupported:
kind: Literal["unsupported"] = "unsupported"
def read_content(known: bool) -> Content | Unsupported:
return Content("content") if known else Unsupported()
Before — flagged A final imported exception can still hide an expected outcome
app/errors.py
from typing import final
@final
class Missing(Exception):
def __init__(self, record_id: str) -> None:
self.record_id = record_id
super().__init__(f"Missing {record_id}")
app/service.py
from app.errors import Missing
def load(record_id: str, exists: bool) -> str:
if not exists:
raise Missing(record_id)
return record_id
After — preferred The error variant retains the identifier needed by its caller
app/outcomes.py
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class Success[T](BaseModel):
kind: Literal["success"] = "success"
value: T
class Error[E](BaseModel):
kind: Literal["error"] = "error"
error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]
app/service.py
from typing import Literal, assert_never
from pydantic import BaseModel, TypeAdapter
from app.outcomes import Error, Result, Success
class Missing(BaseModel):
code: Literal["missing"] = "missing"
record_id: str
def load(record_id: str, exists: bool) -> Result[str, Missing]:
return (
Success[str](value=record_id)
if exists
else Error[Missing](error=Missing(record_id=record_id))
)
def format_result(result: Result[str, Missing]) -> str:
if result.kind == "success":
return result.value
if result.kind == "error":
return f"Missing {result.error.record_id}"
assert_never(result)
codec = TypeAdapter[Result[str, Missing]](Result[str, Missing])
def decode_result(data: str) -> Result[str, Missing]:
return codec.validate_json(data, strict=True)
Before — flagged An imported rejection is absent from the successful return contract
app/errors.py
class Rejected(Exception):
pass
app/service.py
from app.errors import Rejected
def save(name: str, allowed: bool) -> str:
if not allowed:
raise Rejected(f"Denied {name}")
return name
After — preferred An established tagged generic union supports explicit handling and JSON decoding
app/outcomes.py
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class Success[T](BaseModel):
kind: Literal["success"] = "success"
value: T
class Error[E](BaseModel):
kind: Literal["error"] = "error"
error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]
app/service.py
from typing import Literal, assert_never
from pydantic import BaseModel, TypeAdapter
from app.outcomes import Error, Result, Success
class Denied(BaseModel):
code: Literal["denied"] = "denied"
name: str
def save(name: str, allowed: bool) -> Result[str, Denied]:
return (
Success[str](value=name) if allowed else Error[Denied](error=Denied(name=name))
)
def format_result(result: Result[str, Denied]) -> str:
if result.kind == "success":
return result.value
if result.kind == "error":
return f"Denied {result.error.name}"
assert_never(result)
codec = TypeAdapter[Result[str, Denied]](Result[str, Denied])
def decode_result(data: str) -> Result[str, Denied]:
return codec.validate_json(data, strict=True)
Before — flagged A rejection after resource exit has an ordinary return contract
app/errors.py
class Rejected(Exception):
pass
app/service.py
from collections.abc import Generator
from contextlib import contextmanager
from app.errors import Rejected
@contextmanager
def transaction() -> Generator[None]:
yield None
def save(name: str, allowed: bool) -> str:
with transaction():
pass
if not allowed:
raise Rejected(name)
return name
After — preferred A rejection under a resource protocol needs separate migration review
app/errors.py
class Rejected(Exception):
pass
app/service.py
from collections.abc import Generator
from contextlib import contextmanager
from app.errors import Rejected
@contextmanager
def transaction() -> Generator[None]:
yield None
def save(name: str, allowed: bool) -> str:
with transaction():
if not allowed:
raise Rejected(name)
return name
Before — flagged An ordinary guard has no validator rejection protocol
app/errors.py
class Rejected(ValueError):
pass
app/service.py
from app.errors import Rejected
def save(value: str) -> str:
if not value:
raise Rejected("name required")
return value
After — preferred A validator keeps its native exception rejection protocol
app/errors.py
class Rejected(ValueError):
pass
app/model.py
from pydantic import BaseModel, field_validator
from app.errors import Rejected
class Model(BaseModel):
name: str
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if not value:
raise Rejected("name required")
return value