Use when designing, writing, or restructuring Rust types and domain models — a new struct or enum, a state machine, typestate transitions, newtype wrappers, or a public API's type surface — and the goal is a design whose illegal states are unrepresentable.
Install
npx skillscat add platypeeps/sd-ai-command-pack/sd-rust-design Install via the SkillsCat registry.
sd-rust-design
Model the domain with types before writing any logic. Enums carry states and
variants, structs carry the data of a single state, and newtypes enforce
semantic boundaries. The compiler is the first reviewer: a type surface that
composes under check and lint before any behavior exists catches design
errors at the cheapest possible moment.
When to use
Use when introducing or reshaping a Rust type surface: a new struct or enum,
a domain model, a state machine, a constrained value type, or the shape of a
public API. Use it as the modeling reference while laying a skeleton undersd-typed-holes.
Do not use for module and crate layout — that is sd-rust-modules — or for
async and concurrency structure — that is sd-rust-async. For judging a
finished diff, sd-rust-review applies this bar as a local lens, and thesd-review lane owns the verdict.
Arguments
None.
This skill takes no key=value arguments.
Unknown argument names are an error — stop and report them before starting.
Workflow
List the domain's states, transitions, and invariants before touching
code. Every rule you can name is a candidate for a type that enforces
it; a rule with no enforcing type is a runtime bug waiting for a caller.Make illegal states unrepresentable. Treat every
Option<T>field and
boolean flag as a potential leak: replace state-dependent optionals
with enum variants that each carry exactly the data their state needs,
and model "at least one of" with variants rather than runtime
assertions.// Weak: optional fields only meaningful in some states. struct Connection { socket: Option<TcpStream>, error: Option<String>, } // Strong: each variant carries exactly its own data. enum Connection { Idle, Connected { socket: TcpStream }, Failed { error: String }, }Encode state machines as typestate. Each state is its own type; a
transition consumes the current state and returns the next, so the
compiler enforces the transition graph. "Verified" is a transition that
produces a new type, not a boolean property on the old one.Choose the wrapper discipline per value:
- a newtype with a public field when every inner value is valid and
only identity confusion is at stake (a user id versus a team id); - a constrained type with a private field and a single validating
constructor when only some values are valid (email format, non-zero
port, bounded range).
ImplementDisplayandAsRefas the value warrants; neverDerefMut, which bypasses the constraint. Keep test-only constructors
behind a gate:#[cfg(test)]reaches unit tests in the same crate and
nothing else, so atests/integration test that needs one requires a#[cfg(feature = "test-util")]constructor instead. Nothing turns that
feature on for you — no profile implies it — so the test run enables it
explicitly (cargo test --features test-util, or a self path
dev-dependency that carries it). Pick the gate from who must call it,
and never ship an ungated one.
- a newtype with a public field when every inner value is valid and
Parse, don't validate. Fallible constructors return
Result<Self, Error>with the error enum written up front; downstream
code accepts only already-valid types, so no check is ever repeated and
no validate-then-use gap can open.Compose errors railway-style: propagate with
?, convert at
boundaries with.map_err()or, where the crate derives its error
types withthiserroror a similar macro,#[from]— that attribute
is the derive's, not the language's, so name the dependency before
reaching for it. Keep low-level error types out of domain signatures.
Use combinators for linearOption/Resultchains andmatchwhere
branching is what the code is doing — a two-variant match that stays
exhaustive beats a combinator chain that hides the second arm. Keep
exhaustiveness either way: no_wildcard on enums you own.Treat
.clone()as a design decision, not a compiler fix. When the
borrow checker objects, try in order: restructure scope, take a
reference, narrow the borrow,Cowfor sometimes-owned data,Arc/Rcfor genuinely shared ownership — and only then clone, with
a comment saying why.Mark intent on the surface:
#[must_use]where ignoring a return is a
bug,#[non_exhaustive]on public enums that may grow, concrete error
enums rather than boxed trait objects in library code.Compile and lint the type surface before implementing behavior.
Inspect every derive whose behavior is not obvious from the type —
an untagged serde enum with overlapping variant shapes deserializes by
declaration order at runtime and the compiler never flags it. For
skeleton-first delivery, hand the surface tosd-typed-holes.
Safety rules
- Never invent a domain rule to justify a type; every constraint must
trace to a stated requirement or an observed invariant in the sources. - Never weaken an existing invariant while reshaping types. Removing a
validating constructor or exposing a private field is a design change
the user must see called out, not a side effect. - Do not claim compiler guarantees the compiler does not give: derive
behavior, serialization, and runtime semantics need inspection, not
assertion. - This skill informs design and authoring. It carries no review verdict;
thesd-reviewlane owns review outcomes.
Final report
- Type map — each public type, the single business rule it enforces,
and the illegal state it forbids; - Transitions — the state types and the consuming transitions between
them, with any path the compiler now forbids; - Validation boundary — where raw input becomes valid types and which
error enum reports each failure; - Ownership notes — clones that survived the avoidance ladder and why;
- Open risks — derives or runtime behavior the type surface cannot
prove, flagged for review.