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:
| Type | Inner | Defined in | Domain |
|---|---|---|---|
JobId | String | crates/batchalign-types/src/domain.rs | Job identity |
CommandName | String | crates/batchalign-types/src/domain.rs | Batchalign command ("morphotag", "align") |
ReleasedCommand | enum | crates/batchalign-types/src/domain.rs | Closed released command vocabulary |
LanguageCode3 | String | crates/batchalign-types/src/domain.rs | Validated ISO 639-3 code (3 ASCII alpha, lowercased) |
LanguageSpec | enum | crates/batchalign-types/src/domain.rs | Auto or Resolved(LanguageCode3): language at job boundary |
DisplayPath | String | crates/batchalign-types/src/domain.rs | Display-oriented file path within a job ("sample.cha", "subdir/sample.cha") |
NodeId | String | crates/batchalign-types/src/domain.rs | Server/fleet node identity |
BuildOwnedNamespace | Cow<'static, str> | crates/batchalign/src/cache/mod.rs | The 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 |
StampSafeText | Cow<'static, str> | crates/batchalign-types/src/domain.rs | Text 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 |
ReportedEngineName | StampSafeText | crates/batchalign-types/src/domain.rs | An 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 |
CorrelationId | String | crates/batchalign-types/src/domain.rs | Cross-service tracing ID |
NumSpeakers | u32 | crates/batchalign-types/src/domain.rs | Speaker count for diarization |
DurationSeconds | f64 | crates/batchalign-types/src/domain.rs | Duration in seconds |
UnixTimestamp | f64 | crates/batchalign-types/src/domain.rs | Epoch timestamp |
DurationMs | u64 | crates/batchalign-types/src/domain.rs | Duration in milliseconds |
MemoryMb | u64 | crates/batchalign-types/src/domain.rs | Memory amount in megabytes |
WorkerPid | u32 | crates/batchalign-types/src/worker.rs | OS process ID |
AsrTimestampSecs | enum | crates/batchalign-transform/src/asr_postprocess/asr_types.rs | An 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 |
SpeakerIndex | usize | crates/batchalign-transform/src/asr_postprocess/asr_types.rs | Zero-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:
| Type | Used by | Not used by |
|---|---|---|
LanguageSpec | JobSubmission.lang, JobDispatchConfig.lang, RunnerDispatchConfig.lang, JobInfo.lang, JobListItem.lang | Worker IPC, cache keys, MorphosyntaxParams, TranscribeOptions |
LanguageCode3 | Worker IPC, cache keys, MorphosyntaxParams, FA params, all domain-internal language references | , |
Resolution: LanguageSpec::Auto is resolved to a concrete LanguageCode3 at two points:
- Dispatch layer:
resolve_or(&fallback)for commands that need a known language (FA, morphotag, compare). - Transcribe pipeline:
stage_build_chatusesAsrResponse.lang(the ASR engine’s detected language) whenopts.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);
| Type | Source | Lives in |
|---|---|---|
ChatRawText | Word::raw_text() | ../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction) |
ChatCleanedText | Word::cleaned_text() | ../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction) |
SpeakerCode | Utterance.speaker | ../chatter/crates/talkbank-model/src/model/header/codes/speaker.rs (CHAT direction) |
AsrRawText | ASR provider output | crates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction) |
AsrNormalizedText | After 8-stage pipeline | crates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction) |
ChatWordText | ASR-to-CHAT boundary | crates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction) |
Attributes:
#[repr(transparent)]: zero runtime cost, identical layout to innerString.#[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 boilerplateLanguageCode3is not astring_id!because it needs real validationLanguageSpecandWorkerLanguageare 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:
| Context | Pattern |
|---|---|
| JSON/IPC serialization | path.to_string_lossy().into_owned() |
| Tracing/logging | audio_path = %path.display() |
| Format strings | format!("{}", path.display()) |
Passing to &str functions | path.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
| Goal | Attribute | Example |
|---|---|---|
| Newtype as plain value | #[serde(transparent)] | ChatCleanedText → "hello" |
| Flatten inner struct/enum | #[serde(flatten)] on field | MorphotagOptions.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
| Date | Change | Pattern | Rationale |
|---|---|---|---|
| 2026-02 | Text provenance newtypes | Provenance | Bugs 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-02 | JobStatus / FileStatusKind enums | State machine | Replaced stringly-typed status fields; commit cbe0f873 |
| 2026-02 | Flattened command option groups | Struct composition | Reduced duplication while keeping flat JSON across command request types |
| 2026-03 | string_id! / numeric_id! macros | Domain identifier | Eliminated primitive obsession across server codebase; 13 newtypes |
| 2026-03 | CachePolicy, WorTierPolicy enums | Boolean blindness | Replaced ambiguous override_media_cache: bool, write_wor: bool |
| 2026-03 | MorphosyntaxParams, FaParams, AudioContext, PipelineServices | Parameter grouping | Reduced orchestrator signatures from 14-16 params to 3-6 |
| 2026-03 | &Path/PathBuf for audio paths | Path types | Replaced &str/String for file paths; explicit conversion at IPC boundaries |
| 2026-03 | Boundary conversion patterns | All | Codified convert-once-at-boundary: HTTP→JobId, DB→deref, IPC→to_string_lossy, CLI→From<bool> |
| 2026-03 | LanguageSpec enum + validated LanguageCode3 | Sentinel enum | "auto" sentinel leaked into @Languages header (job 696870c7); split into Auto/Resolved enum with validated construction |
| 2026-03 | ClientPath, ServerPath, RepoRelativePath, MediaMappingKey | Path provenance | Untyped paths allowed mixing client/server filesystems; ClientPath omits AsRef<Path> to prevent accidental I/O; details |
Guidelines
- Default to newtypes for any
Stringor number that has domain meaning beyond “some text” or “some count.” Usestring_id!ornumeric_id!: never hand-roll the boilerplate. - Use
Path/PathBuffor file system paths, neverString/&str. Convert to strings only at IPC/JSON boundaries viato_string_lossy(). - Prefer enums over booleans when a third option is plausible or the boolean’s name doesn’t clearly convey both states.
- Convert at the boundary, once. Raw
Stringfrom HTTP extractors →JobId::from()immediately.boolfrom CLI flags →CachePolicy::from()in the dispatch layer. Interior code never handles raw primitives for typed values. - Use
#[serde(untagged)]+#[serde(flatten)]to introduce ADTs without breaking wire format. Always verify with snapshot tests. - Add predicate methods (
is_terminal(),can_cancel()) on state enums, they centralize transition logic and makematchexhaustiveness work for you. - Keep newtypes simple:
#[serde(transparent)]struct with a single field. No phantom types, no generics. Thestring_id!macro generatesDeref<Target=str>for zero-friction migration. - 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. - Function signatures must be self-documenting through types. If you need to read the parameter name to understand what a
&strargument 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).