What if invalid states were not bugs you had to test for—but programs you could not compile?
Most software is secretly a collection of state machines.
A user is logged out, then logged in.
An order is created, then paid for, then shipped, then delivered.
A network connection is disconnected, connecting, connected, or closed.
A file is unopened, opened, written to, and eventually closed.
A database transaction is started, committed, or rolled back.
A rocket is idle, armed, launched, and hopefully not exploded.
The funny thing is that we rarely write software as if these are state machines.
Instead, we create an object.
We give it a status field.
Then we spend the next five years writing if statements to stop people from doing things they should never have been able to do in the first place.
if order.status == "paid" {
ship_order(order);
}
Then someone ships the order twice.
So we add another condition.
if order.status == "paid" && !order.shipped {
ship_order(order);
}
Then refunds appear.
Then cancellations.
Then partial shipments.
Then retries.
Then asynchronous workers.
Then distributed systems.
Then suddenly your innocent little status: String has become a crime scene.
This is where one of the most powerful ideas in modern programming enters the conversation:
What if we encoded the state machine directly into the type system?
Instead of asking at runtime:
“Is this object currently allowed to do this?”
We ask at compile time:
“Can this operation even be expressed on this type?”
That shift sounds small.
It is not.
It changes how you design APIs.
It changes how you think about correctness.
It changes where bugs are allowed to exist.
And Rust, with its ownership system, enums, generics, traits, and zero-cost abstractions, happens to be an absolutely fascinating language for exploring this idea.
Welcome to the world where types do not merely describe data.
They describe time.
They describe history.
They describe what has already happened.
And sometimes, they describe what is allowed to happen next.
The Hidden State Machine Inside Your Code
Let's begin with something extremely ordinary.
Imagine a payment system.
A payment can be:
- Created
- Authorized
- Captured
- Refunded
- Cancelled
We might represent it like this:
struct Payment {
id: String,
status: String,
}
Immediately, the program has a problem.
The type Payment represents all possible states.
A payment that has just been created and a payment that has already been refunded are both:
Payment
The compiler cannot tell them apart.
So this becomes possible:
refund(payment);
Even if the payment was never captured.
Or:
capture(payment);
capture(payment);
Even if capturing twice makes absolutely no sense.
The type system sees only:
Payment
Humans see:
Created → Authorized → Captured → Refunded
That difference is where bugs live.
Traditional programming solves this by storing state as data:
enum PaymentStatus {
Created,
Authorized,
Captured,
Refunded,
Cancelled,
}
struct Payment {
id: String,
status: PaymentStatus,
}
This is already much better.
But the fundamental issue remains.
Every function still accepts:
Payment
So every operation must defend itself.
fn capture(payment: &mut Payment) -> Result<(), Error> {
match payment.status {
PaymentStatus::Authorized => {
payment.status = PaymentStatus::Captured;
Ok(())
}
_ => Err(Error::InvalidState),
}
}
This is runtime validation.
The program can still attempt something illegal.
It just discovers the illegality later.
The compiler allows the program.
The program runs.
The function checks the state.
The function returns an error.
This works.
But there is another way.
Instead of representing state as a value inside an object, we can represent state as part of the object's type.
From Runtime States to Compile-Time States
Imagine these types:
struct CreatedPayment {
id: String,
}
struct AuthorizedPayment {
id: String,
}
struct CapturedPayment {
id: String,
}
struct RefundedPayment {
id: String,
}
Now our transitions become functions:
fn authorize(payment: CreatedPayment) -> AuthorizedPayment {
AuthorizedPayment {
id: payment.id,
}
}
Then:
fn capture(payment: AuthorizedPayment) -> CapturedPayment {
CapturedPayment {
id: payment.id,
}
}
And:
fn refund(payment: CapturedPayment) -> RefundedPayment {
RefundedPayment {
id: payment.id,
}
}
Look carefully at what just happened.
The API itself now describes the state machine.
CreatedPayment
|
| authorize()
v
AuthorizedPayment
|
| capture()
v
CapturedPayment
|
| refund()
v
RefundedPayment
And now this code does not compile:
let payment = CreatedPayment {
id: "123".into(),
};
let refunded = refund(payment);
Why?
Because refund() requires:
CapturedPayment
But we have:
CreatedPayment
This is not a runtime error.
There is no Result.
There is no exception.
There is no if.
There is no test required to discover that this transition is invalid.
The program simply cannot be compiled.
That is an incredibly powerful property.
We have moved a category of bugs from:
“Something that might happen in production.”
to:
“Something that cannot be represented as a valid program.”
The Compiler Becomes Your State Machine Enforcer
This is the mental shift that makes typestate programming so interesting.
Normally, a compiler answers questions like:
- Does this variable exist?
- Are the types compatible?
- Are we borrowing safely?
- Are we accessing valid memory?
With state encoded into types, the compiler starts answering another question:
Is this operation legal at this point in the lifecycle?
Imagine a TCP connection.
A normal object-oriented representation might look like this:
struct Connection {
connected: bool,
}
Then:
fn send(connection: &Connection, data: &[u8]) {
if !connection.connected {
panic!("Not connected");
}
// send data
}
The state is checked at runtime.
But what if we instead had:
struct Disconnected;
struct Connected {
socket: Socket,
}
Then:
impl Disconnected {
fn connect(self) -> Connected {
Connected {
socket: Socket::new(),
}
}
}
And:
impl Connected {
fn send(&self, data: &[u8]) {
// send data
}
fn disconnect(self) -> Disconnected {
Disconnected
}
}
Now the lifecycle becomes visible in the API:
let connection = Disconnected;
let connection = connection.connect();
connection.send(b"Hello, world!");
let connection = connection.disconnect();
But this is impossible:
let connection = Disconnected;
connection.send(b"Hello");
There is no send() method.
Not because we wrote an if statement.
Not because the network rejected it.
Not because a test caught it.
Because the type does not expose the operation.
This is one of the cleanest forms of API design possible.
If you cannot do something, the method does not exist.
Types Can Represent Time
This is where things become slightly philosophical.
Most programmers think types represent structure.
For example:
String
u32
User
Order
Vec<T>
They describe what something is.
But types can also describe where something is in a process.
Consider:
DraftPost
versus:
PublishedPost
Both might contain:
title
content
author
But they are not semantically identical.
One can be edited.
The other might be immutable.
One can be published.
The other cannot.
One can be deleted without consequences.
The other might already have readers.
The data may look similar.
The meaning is completely different.
That means state is not merely data.
State is context.
And context changes what operations are valid.
This is why encoding state into types is so powerful.
The type becomes a description of both:
What this thing is
and:
Where this thing is in its lifecycle
Your program starts gaining a memory of its own history.
The Typestate Pattern
This design approach is commonly known as the Typestate Pattern.
The basic idea is simple:
Represent the state of an object using its type.
Let's build something more realistic.
Imagine an HTTP request builder.
Normally, we might do this:
struct RequestBuilder {
url: Option<String>,
method: Option<String>,
}
Then:
impl RequestBuilder {
fn send(self) -> Result<Response, Error> {
let url = self.url.ok_or(Error::MissingUrl)?;
let method = self.method.ok_or(Error::MissingMethod)?;
// send request
}
}
Again, we are validating required state at runtime.
But suppose we encode the builder's completeness into its type.
struct Missing;
struct Present;
Then:
struct RequestBuilder<UrlState, MethodState> {
url: Option<String>,
method: Option<String>,
_url: std::marker::PhantomData<UrlState>,
_method: std::marker::PhantomData<MethodState>,
}
Now creating a builder gives us:
RequestBuilder<Missing, Missing>
Setting the URL changes the type:
impl<M> RequestBuilder<Missing, M> {
fn url(self, url: String) -> RequestBuilder<Present, M> {
RequestBuilder {
url: Some(url),
method: self.method,
_url: std::marker::PhantomData,
_method: std::marker::PhantomData,
}
}
}
Setting the method does the same:
impl<U> RequestBuilder<U, Missing> {
fn method(self, method: String) -> RequestBuilder<U, Present> {
RequestBuilder {
url: self.url,
method: Some(method),
_url: std::marker::PhantomData,
_method: std::marker::PhantomData,
}
}
}
And now we only implement send() for a fully configured builder.
impl RequestBuilder<Present, Present> {
fn send(self) -> Response {
Response
}
}
Now the compiler understands the lifecycle.
This works:
let request = RequestBuilder::new()
.url("https://example.com".into())
.method("GET".into())
.send();
But this does not:
let request = RequestBuilder::new()
.url("https://example.com".into())
.send();
The method does not exist for:
RequestBuilder<Present, Missing>
That is beautiful.
We did not write:
if method.is_none()
We did not return:
Result<Response, Error>
We changed the problem.
Instead of checking whether the object is valid, we made it impossible to call send() until the object becomes the correct type.
PhantomData: Encoding Information That Does Not Exist at Runtime
The interesting thing about the previous example is this:
UrlState
and:
MethodState
do not necessarily exist as actual runtime values.
They exist for the compiler.
This is where Rust's:
PhantomData<T>
becomes extremely useful.
PhantomData basically tells the compiler:
“This type logically depends on
T, even though no actual value ofTis stored here.”
That means:
RequestBuilder<Present, Missing>
and:
RequestBuilder<Present, Present>
can have the same runtime memory layout.
But the compiler treats them as different types.
This is one of the magical things about type-level state.
You can gain stronger correctness guarantees without necessarily paying runtime costs.
The state machine can disappear during compilation.
The runtime does not need to carry a giant state machine around.
The compiler already used it to prove certain things.
Illegal States Should Be Unrepresentable
There is a famous principle in type-driven programming:
Make illegal states unrepresentable.
This sounds like a slogan.
But it is actually a design philosophy.
Consider a user authentication system.
A poorly designed API might look like this:
struct User {
id: String,
authenticated: bool,
token: Option<String>,
}
Now we can have weird combinations:
authenticated = true
token = None
Or:
authenticated = false
token = Some(...)
Are these valid?
Maybe.
Maybe not.
The problem is that the structure allows combinations that might make no semantic sense.
Instead, we can model the states directly.
struct AnonymousUser {
id: String,
}
struct AuthenticatedUser {
id: String,
token: String,
}
Now the invalid combinations disappear.
A function requiring authentication can accept:
AuthenticatedUser
fn access_dashboard(user: &AuthenticatedUser) {
println!("Welcome {}", user.id);
}
You cannot accidentally pass:
AnonymousUser
The authorization requirement is now visible in the function signature.
That matters enormously.
Because function signatures are documentation.
But unlike comments, they cannot lie as easily.
The State Machine Is the API
When you encode state transitions into types, something fascinating happens.
Your API starts becoming a diagram.
Consider a deployment pipeline.
Code
↓
Built
↓
Tested
↓
Approved
↓
Deployed
We can represent this as types:
struct Code;
struct Built;
struct Tested;
struct Approved;
struct Deployed;
Then:
fn build(_: Code) -> Built {
Built
}
fn test(_: Built) -> Tested {
Tested
}
fn approve(_: Tested) -> Approved {
Approved
}
fn deploy(_: Approved) -> Deployed {
Deployed
}
Now look at the program:
let code = Code;
let built = build(code);
let tested = test(built);
let approved = approve(tested);
let deployed = deploy(approved);
The source code itself tells the story.
And more importantly, the compiler guarantees the order.
This does not compile:
let code = Code;
let deployed = deploy(code);
There is no runtime rule required.
The function signature itself defines the transition graph.
You have essentially encoded this:
Code → Built → Tested → Approved → Deployed
into the type system.
Ownership Makes State Transitions Feel Natural
Rust has a particularly interesting advantage here.
Ownership naturally models transitions.
Look at this:
fn authorize(payment: CreatedPayment) -> AuthorizedPayment
The old state is consumed.
The new state is produced.
That is not accidental.
It perfectly matches the semantics of a state transition.
Before:
CreatedPayment
After:
AuthorizedPayment
You do not have both.
The previous state has been moved.
This prevents a surprisingly common class of mistakes.
Imagine this:
let payment = CreatedPayment {
id: "123".into(),
};
let authorized = authorize(payment);
// payment is gone
You cannot continue using the old CreatedPayment.
Rust's ownership system enforces the timeline.
This is deeper than ordinary type checking.
The type system tells you:
What state you are in.
Ownership tells you:
You cannot pretend you are still in the previous state.
That combination is ridiculously powerful.
Linear Logic Hiding Inside Your Rust Code
Without getting too academic, there is a connection here to an idea from logic and mathematics called linear logic.
Traditional variables can often be copied or reused freely.
But some resources should not behave like that.
Examples:
- A database transaction
- A file handle
- A network socket
- A lock
- A cryptographic secret
- A payment authorization
- A unique ownership token
These things have lifecycles.
You should not casually duplicate them.
A database transaction is a perfect example.
You might have:
struct ActiveTransaction;
struct CommittedTransaction;
struct RolledBackTransaction;
Then:
fn commit(tx: ActiveTransaction) -> CommittedTransaction
or:
fn rollback(tx: ActiveTransaction) -> RolledBackTransaction
Once committed:
let committed = commit(tx);
You cannot also roll it back.
Because tx was consumed.
That means this is impossible:
let committed = commit(tx);
let rolled_back = rollback(tx);
The compiler says:
You already used that resource.
Which is exactly what we want.
A transaction should not simultaneously be committed and rolled back.
Reality itself does not support that state.
Why should your type system?
Enums vs Typestate
At this point, you might ask:
Why not just use enums?
Excellent question.
Enums are fantastic.
Consider:
enum Order {
Created {
id: String,
},
Paid {
id: String,
},
Shipped {
id: String,
},
Delivered {
id: String,
},
}
This is a very strong representation.
It prevents impossible data combinations.
But enums and typestate solve slightly different problems.
With enums, one variable can represent multiple possible states:
Order
You then pattern match:
match order {
Order::Created { .. } => {}
Order::Paid { .. } => {}
Order::Shipped { .. } => {}
Order::Delivered { .. } => {}
}
Typestate instead often creates separate types:
CreatedOrder
PaidOrder
ShippedOrder
DeliveredOrder
Then functions can restrict themselves to specific states.
fn ship(order: PaidOrder) -> ShippedOrder
The difference is subtle but important.
Enums say:
“This value may currently be one of these states.”
Typestate says:
“This function only accepts this exact state.”
In practice, the two approaches can complement each other.
Enums are excellent when you need to inspect dynamic state.
Typestate is excellent when you want to constrain an API.
The question is not:
Which one is better?
The question is:
Where do you want correctness to live?
A File API That Cannot Forget to Close
Let's imagine designing a file abstraction.
A naive API:
struct File {
handle: Option<Handle>,
}
Then:
fn read(file: &File) -> Result<Vec<u8>, Error> {
let handle = file.handle.as_ref()
.ok_or(Error::FileClosed)?;
// read
}
Again, runtime state checking.
Now consider typestate:
struct ClosedFile {
path: String,
}
struct OpenFile {
handle: Handle,
}
Then:
impl ClosedFile {
fn open(self) -> OpenFile {
OpenFile {
handle: open_handle(self.path),
}
}
}
And:
impl OpenFile {
fn read(&self) -> Vec<u8> {
// read
vec![]
}
fn close(self) -> ClosedFile {
ClosedFile {
path: "file.txt".into(),
}
}
}
Now:
let file = ClosedFile {
path: "data.txt".into(),
};
let file = file.open();
let data = file.read();
let file = file.close();
You cannot read after closing because:
ClosedFile
does not expose:
read()
The lifecycle is enforced by the API.
This is the type system acting like a traffic controller.
You may proceed.
You may not proceed.
And it makes that decision before your program runs.
State Machines Become Composable
The really interesting part begins when we combine typestate with generics.
Imagine a generic document:
struct Draft;
struct Review;
struct Published;
Then:
struct Document<State> {
title: String,
content: String,
_state: std::marker::PhantomData<State>,
}
Now we can implement operations selectively.
impl Document<Draft> {
fn submit_for_review(self) -> Document<Review> {
Document {
title: self.title,
content: self.content,
_state: std::marker::PhantomData,
}
}
}
Then:
impl Document<Review> {
fn publish(self) -> Document<Published> {
Document {
title: self.title,
content: self.content,
_state: std::marker::PhantomData,
}
}
}
And:
impl Document<Published> {
fn public_url(&self) -> String {
format!("https://example.com/{}", self.title)
}
}
The generic container:
Document<State>
represents the concept.
The generic parameter represents the lifecycle position.
This scales beautifully.
You are not creating entirely unrelated structures.
You are saying:
“A document exists in different compile-time states.”
That is a very elegant abstraction.
But Can You Overdo This?
Absolutely.
And this is important.
Typestate programming can become a type-level fever dream.
You start with:
Document<Draft>
Then:
Document<Draft, Unsigned, Unapproved, Unencrypted>
Then:
Document<
Draft,
Unsigned,
Unapproved,
Unencrypted,
Local,
Uncompressed,
Unindexed
>
Congratulations.
You have successfully turned your application into a doctoral thesis.
The compiler is powerful.
But humans still need to read the code.
The goal is not:
Encode every possible fact into the type system.
The goal is:
Encode important invariants where doing so makes invalid behavior impossible or significantly harder.
Typestate is most valuable when:
- State transitions are strict.
- Invalid transitions are dangerous.
- Runtime checks are repetitive.
- The lifecycle is central to the API.
- Resources have ownership semantics.
- Bugs are expensive.
It might be unnecessary for:
- Simple CRUD entities.
- Highly dynamic workflows.
- States determined entirely by external systems.
- Rapidly changing business rules.
There is always a trade-off.
Strong types are not free.
They cost conceptual complexity.
The question is whether that complexity is worth buying.
Distributed Systems Are Full of State Machines
This idea becomes even more interesting when we move beyond local programs.
Distributed systems are basically giant state machines wearing trench coats.
A message can be:
Created
Queued
Delivered
Acknowledged
Failed
Retried
Dead-Lettered
A service can be:
Starting
Healthy
Degraded
Unavailable
Recovering
A consensus node can be:
Follower
Candidate
Leader
A transaction can be:
Prepared
Committed
Aborted
And distributed systems are notoriously difficult because invalid transitions happen across:
- machines
- networks
- processes
- time
- failures
You cannot encode the entire distributed world into Rust's type system.
The network does not care about your generics.
A remote server can always behave unexpectedly.
But you can encode your local protocol guarantees.
For example, a client library might ensure that:
Handshake must happen before authentication.
Authentication must happen before requests.
Shutdown prevents future requests.
The network remains uncertain.
But your API becomes safer.
This is a crucial distinction.
Types cannot eliminate uncertainty.
But they can eliminate nonsense.
And eliminating nonsense is already an incredible engineering achievement.
Session Types: Typestate's Bigger Cousin
If typestate describes the lifecycle of an object, there is an even more fascinating concept called session types.
Session types attempt to describe communication protocols.
Imagine a protocol:
Client → Server: Hello
Server → Client: Challenge
Client → Server: Response
Server → Client: Success
A session type can describe what communication is expected next.
Conceptually:
Send Hello
↓
Receive Challenge
↓
Send Response
↓
Receive Success
The type system can then help ensure you do not send messages in the wrong order.
This is where programming starts feeling almost futuristic.
The compiler is no longer just checking data structures.
It is checking conversations.
It is checking protocols.
It is checking sequences of events.
That idea should make every backend engineer slightly excited.
Because how many production bugs are really just:
“Two systems disagreed about what was supposed to happen next.”
The Type System as a Proof Engine
There is another way to think about all of this.
A program is not only a sequence of instructions.
A program can also be a proof.
When we write:
fn capture(payment: AuthorizedPayment) -> CapturedPayment
we are making a claim:
A captured payment can only be produced from an authorized payment.
The compiler checks whether our code obeys the rules required to make that claim meaningful.
If a function requires:
AuthenticatedUser
then calling that function is evidence that authentication has already happened somewhere in the program's control flow.
The type becomes a proof of a previous event.
That is mind-bending.
Consider:
fn deploy(app: TestedApplication)
The existence of:
TestedApplication
is evidence that testing happened.
Or at least that the only legitimate way to construct that type requires testing.
This is where API design becomes incredibly important.
If developers can casually construct:
TestedApplication
then the guarantee is meaningless.
So constructors matter.
Visibility matters.
Modules matter.
The strength of the state machine depends on controlling how states are created.
Smart Constructors and Protected Transitions
Imagine this:
pub struct AuthorizedPayment {
id: String,
}
Anyone could potentially create one.
That defeats the purpose.
Instead, we can make internal fields private:
pub struct AuthorizedPayment {
id: String,
}
Then only expose transitions.
impl CreatedPayment {
pub fn authorize(self) -> AuthorizedPayment {
AuthorizedPayment {
id: self.id,
}
}
}
The outside world cannot simply say:
AuthorizedPayment {
id: "123".into(),
};
if the fields are private.
Now the state becomes meaningful.
You have created a controlled state transition system.
The only paths through the state machine are the paths exposed by the API.
That is exactly what we want.
Error Handling Still Exists
A common misunderstanding is that encoding state into types removes errors.
It does not.
It removes certain categories of errors.
Suppose:
fn connect(self) -> Connected
In reality, a network connection might fail.
So the real signature becomes:
fn connect(self) -> Result<Connected, ConnectionError>
The type system guarantees:
You cannot send data before connecting.
But it cannot guarantee:
The network will always allow you to connect.
Those are different things.
Types are excellent at modeling logical impossibilities.
They cannot eliminate physical uncertainty.
For example:
Sending before connecting
is logically invalid.
But:
Connection timed out
is a real-world failure.
The first can be eliminated through types.
The second requires runtime error handling.
Great engineering comes from knowing which category you are dealing with.
The Most Interesting API Is Often the One That Lets You Do Less
We often judge APIs by how much they can do.
But sometimes the best API is defined by what it refuses to let you do.
A good API might say:
You cannot:
- Commit a transaction twice.
- Read a closed file.
- Send through a disconnected socket.
- Publish an incomplete article.
- Deploy unapproved code.
- Spend an already-consumed token.
- Access a dashboard without authentication.
That is not a limitation.
That is intelligence.
The API understands its own rules.
And when the rules are embedded into types, developers do not have to remember everything.
The compiler remembers.
This is one of the most underrated benefits of strong typing.
Humans are unreliable state machines.
We forget.
We misunderstand.
We copy-paste code.
We call functions in the wrong order.
We assume.
We are tired.
The compiler is not tired.
The compiler does not care that it is 2 AM.
The compiler does not say:
“I think this transition is probably fine.”
It either accepts the program or rejects it.
And honestly, sometimes we need that kind of friend.
State Machines Are Everywhere
Once you start seeing state machines, you cannot stop.
Your login flow is a state machine.
Your shopping cart is a state machine.
Your CI/CD pipeline is a state machine.
Your database connection is a state machine.
Your Kubernetes pod lifecycle is a state machine.
Your compiler is a state machine.
Your music player is a state machine.
Your coffee machine is probably a state machine.
Even your own software development process looks suspiciously like:
Idea
↓
Prototype
↓
Buggy
↓
Very Buggy
↓
Rewrite
↓
Production
↓
Emergency
We live inside transitions.
Programming is largely the art of deciding:
What can happen next?
And that is exactly what a state machine describes.
The type system gives us a way to move that question earlier.
Instead of discovering the answer while the program is running, we can encode parts of the answer into the program's structure.
The Future of Correctness Is Moving Left
Software engineering has spent decades moving problems earlier.
We moved from:
Production bug
to:
Runtime error
Then to:
Test failure
Then to:
Static analysis warning
Then to:
Compiler error
Every step leftward is cheaper.
A production bug might cost money.
A failed test costs developer time.
A compiler error costs a few seconds.
Encoding state machines in types is part of that movement.
We are taking business and lifecycle rules that would normally be enforced during execution and asking:
Can the compiler help us enforce these before execution?
Sometimes the answer is no.
But sometimes the answer is beautifully yes.
The Real Lesson
The biggest lesson here is not:
“Use
PhantomDataeverywhere.”
Please do not.
The real lesson is much bigger.
When designing software, ask:
What states can this thing exist in?
Then ask:
Which transitions are valid?
Then:
Which transitions are impossible?
And finally:
Can the type system represent those rules?
If the answer is yes, you may be able to design an API where entire categories of bugs disappear.
Not because you tested harder.
Not because you wrote better comments.
Not because your developers became perfect.
But because the invalid program stopped being expressible.
That is one of the deepest ideas in programming.
The type system is not merely there to complain when you add a string to an integer.
It can model constraints.
It can model protocols.
It can model lifecycles.
It can model history.
And in the right hands, it can model the flow of time itself.
Final Thoughts
State machines are one of those concepts that seem simple when you first encounter them.
A few circles.
A few arrows.
A few transitions.
Then you start building real systems and realize that almost everything important is governed by state.
The question is not whether your application has state machines.
It does.
The question is where those state machines live.
Do they live in:
if statements?
Do they live in:
status == "something"
Do they live in documentation nobody reads?
Do they live in the heads of senior engineers?
Or do they live somewhere much more reliable?
The type system.
Encoding state machines into types is ultimately about making software more honest.
A disconnected connection should not look like a connected connection.
An unauthenticated user should not look like an authenticated user.
An unapproved deployment should not look like a deployable application.
A closed file should not expose read().
A payment that has never been captured should not be refundable.
The closer your types match reality, the less your program has to constantly defend itself from impossible situations.
And that might be one of the most beautiful goals in software engineering:
Do not merely detect invalid states.
Design your system so that invalid states struggle to exist.
Because the best bug is not the one you catch in production.
The best bug is the one the compiler never allows you to write.
And once you understand that, Rust's type system starts looking less like a strict teacher and more like something far more interesting.
A machine capable of enforcing the rules of your universe.
One state transition at a time.
Top comments (0)