Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Type-Driven Design

Status: Current Last updated: 2026-09-15 18:27 EDT

Batchalign uses Rust’s type system to encode domain invariants at compile time. This document catalogs the patterns in use, explains when to reach for each one, and records the serde techniques that keep the wire format stable while the internal types evolve.

Patterns

1. Domain Identifier Newtypes (string_id! / numeric_id!)

Problem: Functions with signatures like fn submit(job_id: &str, command: &str, lang: &str, filename: &str) are impossible to read at a glance, every parameter is &str. Swapping arguments compiles silently.

Solution: Zero-cost newtypes generated by two macros in crates/batchalign-types/src/macros.rs.

// crates/batchalign-types/src/domain.rs
string_id!(
    /// Server-assigned UUID (v4) for a job.
    pub JobId
);

numeric_id!(
    /// Duration measured in milliseconds.
    pub DurationMs(u64) [Eq]
);

string_id! generates: Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Display, From<String>, From<&str>, Into<String>, Deref<Target=str>, AsRef<str>, PartialEq<&str>, Borrow<str>, Default. The Deref<Target=str> impl enables gradual migration, callers that need &str auto-coerce.

numeric_id! generates the same set adapted for numeric inner types. Append [Eq] for integer types that need Eq + Hash.

All generated types use #[serde(transparent)]: the wire format stays as bare strings or numbers.

Complete inventory:

TypeInnerDefined inDomain
JobIdStringcrates/batchalign-types/src/domain.rsJob identity
CommandNameStringcrates/batchalign-types/src/domain.rsBatchalign command ("morphotag", "align")
ReleasedCommandenumcrates/batchalign-types/src/domain.rsClosed released command vocabulary
LanguageCode3Stringcrates/batchalign-types/src/domain.rsValidated ISO 639-3 code (3 ASCII alpha, lowercased)
LanguageSpecenumcrates/batchalign-types/src/domain.rsAuto or Resolved(LanguageCode3): language at job boundary
DisplayPathStringcrates/batchalign-types/src/domain.rsDisplay-oriented file path within a job ("sample.cha", "subdir/sample.cha")
NodeIdStringcrates/batchalign-types/src/domain.rsServer/fleet node identity
BuildOwnedNamespaceCow<'static, str>crates/batchalign/src/cache/mod.rsThe one representation behind the cache namespace and revisions this build owns: UtrAsrCacheNamespace, RevAsrModelRevision, SpeakerEvidenceModelRevision and SpeakerNormalizationRevision. Built as a literal (const fn literal) or derived from literals and files compiled into the binary (derived). Each wrapper keeps its own newtype, so a cache task constant still refuses one where another belongs. It replaced EngineVersion, a worker-version type deleted from batchalign-types once nothing used it
StampSafeTextCow<'static, str>crates/batchalign-types/src/domain.rsText that cannot change a provenance stamp’s structure. Refuses blank text, surrounding whitespace (StampSafeText::WHITESPACE, the Unicode White_Space set) and the stamp structure characters (StampSafeText::STAMP_STRUCTURE: vertical bar, semicolon, closing bracket, newline, carriage return), with InvalidStampSafeText. Routes in: TryFrom (also serde), const fn from_static (a literal checked at compile time inside const { }) and join with a StampJoiner (Concat, Plus, Colon, At). Its JSON Schema pattern is generated from the same two lists. Provenance’s StampFieldValue wraps it
ReportedEngineNameStampSafeTextcrates/batchalign-types/src/domain.rsAn engine identity a worker reported, in a capability report or on a result. A wrapper over StampSafeText; the only constructor is TryFrom, shared by deserialization, which applies the StampSafeText check; there is no infallible From
CorrelationIdStringcrates/batchalign-types/src/domain.rsCross-service tracing ID
NumSpeakersu32crates/batchalign-types/src/domain.rsSpeaker count for diarization
DurationSecondsf64crates/batchalign-types/src/domain.rsDuration in seconds
UnixTimestampf64crates/batchalign-types/src/domain.rsEpoch timestamp
DurationMsu64crates/batchalign-types/src/domain.rsDuration in milliseconds
MemoryMbu64crates/batchalign-types/src/domain.rsMemory amount in megabytes
WorkerPidu32crates/batchalign-types/src/worker.rsOS process ID
AsrTimestampSecsenumcrates/batchalign-transform/src/asr_postprocess/asr_types.rsAn ASR provider’s endpoint in seconds: Observed(f64), including a real zero, or Absent. Serialized untagged, so it crosses the wire as a number or as null; an absent endpoint becomes an untimed word rather than time zero
SpeakerIndexusizecrates/batchalign-transform/src/asr_postprocess/asr_types.rsZero-based speaker index in a recording

When to use: Any String or number that identifies a domain concept. If a parameter name is needed to understand what the type represents, it should be a newtype.

2. Validated Newtypes and Sentinel Enums

Problem: LanguageCode3 was a string_id! newtype that accepted any string, including "auto", "", "lol". When the CLI passed --lang auto, the sentinel leaked through the entire pipeline and produced @Languages: auto in the CHAT header (job 696870c7-02b).

Solution: Two complementary types that make the sentinel impossible to confuse with a real value.

LanguageCode3: a validated newtype (not string_id!). Construction rejects anything that isn’t exactly 3 ASCII letters:

impl LanguageCode3 {
    pub fn try_new(s: &str) -> Result<Self, InvalidLanguageCode> {
        if s.len() == 3 && s.bytes().all(|b| b.is_ascii_alphabetic()) {
            Ok(Self(s.to_ascii_lowercase()))
        } else {
            Err(InvalidLanguageCode(s.to_string()))
        }
    }
}

From<&str> keeps a debug_assert! for test-time safety. Deserialize validates and rejects bad codes.

LanguageSpec: a sentinel enum at the CLI/job boundary:

pub enum LanguageSpec {
    Auto,
    Resolved(LanguageCode3),
}

Serde: "auto"Auto (case-insensitive), any valid 3-letter code → Resolved. Invalid strings are rejected at deserialization.

Where each type lives:

TypeUsed byNot used by
LanguageSpecJobSubmission.lang, JobDispatchConfig.lang, RunnerDispatchConfig.lang, JobInfo.lang, JobListItem.langWorker IPC, cache keys, MorphosyntaxParams, TranscribeOptions
LanguageCode3Worker IPC, cache keys, MorphosyntaxParams, FA params, all domain-internal language references,

Resolution: LanguageSpec::Auto is resolved to a concrete LanguageCode3 at two points:

  1. Dispatch layer: resolve_or(&fallback) for commands that need a known language (FA, morphotag, compare).
  2. Transcribe pipeline: stage_build_chat uses AsrResponse.lang (the ASR engine’s detected language) when opts.lang == "auto".

When to use this pattern: Any domain identifier that has a sentinel/wildcard value ("auto", "all", "*") that must not leak into output. Split the sentinel into an enum variant and validate the concrete type on construction.

3. Provenance Newtypes

Problem: Bare String fields don’t say where a value came from or what transformations it underwent. Swapping “cleaned text” for “raw text” compiles silently and corrupts output.

Solution: Wrap each provenance in a zero-cost newtype.

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
pub struct ChatCleanedText(String);
TypeSourceLives in
ChatRawTextWord::raw_text()../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction)
ChatCleanedTextWord::cleaned_text()../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction)
SpeakerCodeUtterance.speaker../chatter/crates/talkbank-model/src/model/header/codes/speaker.rs (CHAT direction)
AsrRawTextASR provider outputcrates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction)
AsrNormalizedTextAfter 8-stage pipelinecrates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction)
ChatWordTextASR-to-CHAT boundarycrates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction)

Attributes:

  • #[repr(transparent)]: zero runtime cost, identical layout to inner String.
  • #[serde(transparent)]: serializes as a plain JSON string, invisible at the wire boundary.
  • Simple newtypes, not phantom-type generics.

When to use: Any String field where two values of different provenance could be confused.

Path provenance: The path newtypes (ClientPath, ServerPath, RepoRelativePath, MediaMappingKey) extend this pattern to encode machine boundaries – which side of the client/server divide a value lives on. ClientPath deliberately omits AsRef<Path> to prevent accidental I/O. See the typed path provenance page for the full design.

4. Flattened Struct Composition

Problem: Command option structs tend to accumulate duplicated common fields, which makes request types noisy and encourages copy/paste drift between command families.

Solution: Factor the shared fields into a dedicated struct and flatten it at the wire boundary so JSON stays ergonomic while Rust keeps explicit grouping.

pub struct MorphotagOptions {
    #[serde(flatten)]
    pub common: CommonOptions,
    pub retokenize: bool,
    pub skipmultilang: bool,
    pub merge_abbrev: bool,
}

Serialized JSON stays flat even though the Rust model is grouped:

{"command":"morphotag","clean":false,"verbose":false,"retokenize":false}

This pattern now shows up across command option structs in crates/batchalign/src/types/options.rs, where one CommonOptions payload is reused by multiple command-specific types.

align applies the same pattern at two boundaries. The CLI groups UTR selection/compatibility flags separately from UTR algorithm tuning and groups word-boundary policies separately from unrelated command flags. Lowering then produces persisted AlignUtrOptions and AlignBoundaryOptions values. Serde flattening preserves the existing flat job JSON, so the internal type model can be made harder to misuse without breaking stored jobs or clients.

flowchart LR
    A[AlignArgs] --> S[AlignUtrSelectionArgs]
    A --> T[AlignUtrTuningArgs]
    A --> B[AlignBoundaryArgs]
    S --> U[AlignUtrOptions]
    T --> U
    B --> P[AlignBoundaryOptions]
    U --> O[AlignOptions]
    P --> O
    O --> F[Typed FA dispatch and projection policies]

This is more than cosmetic organization: constructors must supply each policy group explicitly, while compatibility tests verify that CLI spelling and the serialized wire shape remain unchanged.

When to use: Shared wire fields that belong together semantically but should not introduce extra nesting in request/response JSON.

5. State Machine Enums

Problem: Job and file status are strings with implicit transition rules. Typos like "compelted" or illegal transitions (cancelled → running) are only caught by downstream code.

Solution: A closed enum with predicate methods that encode the state machine.

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
    Queued,
    Running,
    Completed,
    Failed,
    Cancelled,
    Interrupted,
}

impl JobStatus {
    pub fn is_terminal(self) -> bool {
        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
    }
    pub fn is_active(self) -> bool {
        matches!(self, Self::Queued | Self::Running)
    }
    pub fn can_cancel(self) -> bool {
        self.is_active()
    }
    pub fn can_restart(self) -> bool {
        matches!(self, Self::Failed | Self::Cancelled | Self::Interrupted)
    }
}

FileStatusKind follows the same pattern with Queued, Processing, Done, Error, Interrupted.

Serde note: #[serde(rename_all = "lowercase")] maps Completed"completed" for Python compatibility. Display and FromStr impls mirror this for logs and CLI output.

When to use: Any status/phase field with a finite set of legal values and transition rules.

6. Configuration Enums

Problem: A boolean flag like force_cpu: bool cannot express a third state, and every knob that starts as two values tends to grow a third.

Solution: A small enum with serde rename.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryTierKind {
    Small,
    Medium,
    Large,
    Fleet,
}

Each variant names a real host class and carries its own bootstrap decision, so adding a fifth tier forces every match over it to state an answer. A boolean constrained: bool would have collapsed Small and Medium, which differ in exactly the way that matters (task bootstrap versus lazy-profile bootstrap).

When to use: Any configuration knob with more than two values, or where the two-value case may grow.

Macro Boundaries

Macros are part of the type story, but they are not the architecture.

The current policy is:

  • keep macros only for stable mechanical boilerplate
  • keep one canonical macro definition for shared domain newtypes in batchalign-types/src/macros.rs
  • do not duplicate macro layers in app crates just because it is convenient
  • do not use macros to hide validation, sentinel semantics, or workflow policy

This is why:

  • string_id! / numeric_id! are appropriate for transparent newtype boilerplate
  • LanguageCode3 is not a string_id! because it needs real validation
  • LanguageSpec and WorkerLanguage are enums, not macro-generated string wrappers, because they encode sentinel semantics

The broader rule is simple: macros should compress repetition after the domain shape is already correct. They should not be used as a substitute for deciding what the correct domain shape is.

Boundary Conversion Patterns

Newtypes are enforced in domain code. At system boundaries where external types are required, explicit conversion happens once.

Route Handlers (HTTP → Domain)

Axum path extractors produce String. Convert immediately at the handler entry point:

pub(crate) async fn get_job(
    State(state): State<Arc<AppState>>,
    Path(job_id): Path<String>,          // axum gives String
) -> Result<Json<JobInfo>, ServerError> {
    let job_id = JobId::from(job_id);    // convert once
    state.store.get(&job_id).await       // domain code uses &JobId
        .map(Json)
        .ok_or_else(|| ServerError::JobNotFound(job_id))
}

Database Boundary (Domain → SQL)

SQLite bindings need &str. The Deref<Target=str> impl on string_id! types makes this transparent, pass &job_id and deref coercion handles it:

sqlx::query("UPDATE jobs SET status = ? WHERE job_id = ?")
    .bind(&status_str)
    .bind(&*job_id)  // JobId → &str via Deref
    .execute(pool).await

IPC / JSON Serialization (Domain → Python Worker)

Python workers communicate via JSON-lines. File paths use Path::to_string_lossy() at the serialization boundary:

let audio_path_str = audio_path.to_string_lossy();  // &Path → Cow<str>
let item = serde_json::json!({
    "audio_path": &*audio_path_str,
    "lang": &*lang,  // LanguageCode3 → &str via Deref
});

File Paths (Path / PathBuf vs String)

Audio paths use std::path::Path/PathBuf in Rust domain code. Since Path derefs to OsStr (not str), conversion to strings requires explicit .to_string_lossy() or .display() at boundaries:

ContextPattern
JSON/IPC serializationpath.to_string_lossy().into_owned()
Tracing/loggingaudio_path = %path.display()
Format stringsformat!("{}", path.display())
Passing to &str functionspath.to_str().unwrap_or("")

HashMap Key Lookups

string_id! generates Borrow<str>, so HashMap<JobId, Job>::get(job_id) works directly when job_id: &JobId. For maps keyed by String that receive a newtype, use deref: map.get(&*filename) or map.get::<str>(&filename).

CLI Flags → Enums (From<bool>)

Boolean CLI flags convert to domain enums once at the dispatch layer:

// dispatch layer
let cache_policy = CachePolicy::from(opts.override_media_cache);  // bool → enum
let wor_tier = WorTierPolicy::from(opts.write_wor);

// orchestrator: never sees booleans
process_fa(chat_text, audio, services, &FaParams {
    cache_policy,
    wor_tier,
    ..
})

Serde Techniques Reference

GoalAttributeExample
Newtype as plain value#[serde(transparent)]ChatCleanedText"hello"
Flatten inner struct/enum#[serde(flatten)] on fieldMorphotagOptions.common
Skip None#[serde(skip_serializing_if = "Option::is_none")]Optional fields
Lowercase variants#[serde(rename_all = "lowercase")]JobStatus
Default on missing#[serde(default)] or #[serde(default = "fn")]worker/timing fields

Key constraint: All IPC types in crates/batchalign-types/src/worker.rs and the crates/batchalign-types/src/worker_v2/ module (re-exported via crates/batchalign/src/types/worker.rs and crates/batchalign/src/types/worker_v2.rs) must produce JSON identical to the Python Pydantic models. Snapshot tests in crates/batchalign/tests/json_compat.rs (with fixtures under crates/batchalign/tests/snapshots/json_compat__*.snap) enforce this, if a serde attribute changes the wire format, the snapshot diff will catch it.

Decision Record

DateChangePatternRationale
2026-02Text provenance newtypesProvenanceBugs from mixing raw/cleaned text; CHAT-direction types now in ../chatter/crates/talkbank-model/src/text_types.rs, ASR-direction types in crates/batchalign-transform/src/asr_postprocess/asr_types.rs
2026-02JobStatus / FileStatusKind enumsState machineReplaced stringly-typed status fields; commit cbe0f873
2026-02Flattened command option groupsStruct compositionReduced duplication while keeping flat JSON across command request types
2026-03string_id! / numeric_id! macrosDomain identifierEliminated primitive obsession across server codebase; 13 newtypes
2026-03CachePolicy, WorTierPolicy enumsBoolean blindnessReplaced ambiguous override_media_cache: bool, write_wor: bool
2026-03MorphosyntaxParams, FaParams, AudioContext, PipelineServicesParameter groupingReduced orchestrator signatures from 14-16 params to 3-6
2026-03&Path/PathBuf for audio pathsPath typesReplaced &str/String for file paths; explicit conversion at IPC boundaries
2026-03Boundary conversion patternsAllCodified convert-once-at-boundary: HTTP→JobId, DB→deref, IPC→to_string_lossy, CLI→From<bool>
2026-03LanguageSpec enum + validated LanguageCode3Sentinel enum"auto" sentinel leaked into @Languages header (job 696870c7); split into Auto/Resolved enum with validated construction
2026-03ClientPath, ServerPath, RepoRelativePath, MediaMappingKeyPath provenanceUntyped paths allowed mixing client/server filesystems; ClientPath omits AsRef<Path> to prevent accidental I/O; details

Guidelines

  1. Default to newtypes for any String or number that has domain meaning beyond “some text” or “some count.” Use string_id! or numeric_id!: never hand-roll the boilerplate.
  2. Use Path/PathBuf for file system paths, never String/&str. Convert to strings only at IPC/JSON boundaries via to_string_lossy().
  3. Prefer enums over booleans when a third option is plausible or the boolean’s name doesn’t clearly convey both states.
  4. Convert at the boundary, once. Raw String from HTTP extractors → JobId::from() immediately. bool from CLI flags → CachePolicy::from() in the dispatch layer. Interior code never handles raw primitives for typed values.
  5. Use #[serde(untagged)] + #[serde(flatten)] to introduce ADTs without breaking wire format. Always verify with snapshot tests.
  6. Add predicate methods (is_terminal(), can_cancel()) on state enums, they centralize transition logic and make match exhaustiveness work for you.
  7. Keep newtypes simple: #[serde(transparent)] struct with a single field. No phantom types, no generics. The string_id! macro generates Deref<Target=str> for zero-friction migration.
  8. Boundary rule: Newtypes live inside Rust. At JSON/Python boundaries, they serialize transparently (newtypes via #[serde(transparent)]) or structurally (enums via #[serde(untagged)]/rename_all). Python never sees the Rust type names.
  9. Function signatures must be self-documenting through types. If you need to read the parameter name to understand what a &str argument represents, it should be a newtype.

This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).