Session types were invented in 1993. Thirty years later, most networked services still validate protocol state with hand-written runtime checks, if they validate it at all. Your type system has nothing to say about whether a client sends PONG before HELLO, or whether a connection leaks because someone forgot to close it.
The research community has had a solution for this for decades. The problem was never theoretical. The problem was that session types require a feature most mainstream languages spent thirty years refusing to implement: linear types.
A session type is a state machine that the compiler checks for you
Every network protocol is a state machine. A simple request-response protocol looks like this: the client sends an i32, the server responds with a String, and then the channel closes. If the client tries to read before writing, or if either side forgets to close the connection, the protocol is violated.
In a typical codebase, you enforce this with comments, convention, and maybe a handwritten enum that tracks state at runtime. That enum is a tiny interpreter. It lives in your head, in your docs, and in your bug tracker.
Session types move that state machine into the type system. The type of a channel changes after every operation. Send an integer, and the channel’s type becomes “waiting to receive a string.” Receive the string, and the type becomes “must close.” Use the channel out of order, and you get a compile error, not a protocol violation in production.
Here is what that looks like in Rust, using the type-state pattern to encode a session type without any external dependencies:
use std::marker::PhantomData;
// Protocol states: Send<i32>, Recv<String>, Close
struct Start;
struct Sent;
struct Recvd;
struct Chan<S> {
_state: PhantomData<S>,
}
impl Chan<Start> {
fn connect() -> Self {
Chan { _state: PhantomData }
}
fn send(self, _value: i32) -> Chan<Sent> {
Chan { _state: PhantomData }
}
}
impl Chan<Sent> {
fn recv(self) -> (String, Chan<Recvd>) {
(String::from("ok"), Chan { _state: PhantomData })
}
}
impl Chan<Recvd> {
fn close(self) {
// Channel consumed and dropped.
}
}
fn correct_client() {
let c = Chan::<Start>::connect();
let c = c.send(42);
let (msg, c) = c.recv();
println!("{}", msg);
c.close();
}
This compiles because the sequence matches the protocol exactly. Try to call recv on a Chan<Start>, or call send twice, and the compiler rejects it. The state machine is no longer a runtime concern. It is a type error.
That is a genuinely powerful idea. It is also an idea that most languages could not express until very recently.
Linear types are the gatekeeper, and they are ergonomically brutal
The type system trick above only works because every method consumes self. You cannot use a channel after you have moved it. This is linearity: every value must be used exactly once, and the compiler enforces it.
Linearity is not a niche preference. It is a hard requirement for session types. If you could copy a channel and send on both copies, the protocol state would diverge. If you could drop a channel without closing it, the other side would hang forever. The type system must track the channel through its entire lifetime, with no aliasing and no leaks.
Mainstream languages spent decades building type systems that make aliasing easy and memory management implicit. C, C++, Java, Python, JavaScript, Go. None of them have linear types. In those languages, a session type implementation would need to fall back to runtime checks, which defeats the point.
OCaml and Haskell have powerful type systems, but even they do not enforce linearity by default. You can bind a channel to a variable and ignore it. The garbage collector will clean it up eventually, but “eventually” is not good enough for a protocol that requires an explicit close message.
Rust is the first mainstream language with a borrow checker that approximates linearity. That is not a coincidence. Rust’s ownership model is exactly what session types needed to escape the research lab. The session_types crate on crates.io implements full binary and multiparty session types on top of Rust’s ownership system. It works. It is also finicky to use, because linear reasoning is finicky.
Session types solve a problem that most developers do not feel acutely
Here is the uncomfortable truth. The industry built an entire distributed computing stack without session types, and it mostly worked. REST, JSON over HTTP, gRPC, GraphQL. These are all untyped or loosely typed at the protocol level. A gRPC client can call methods out of order, pass malformed payloads, or leave streams dangling. The errors surface at runtime, usually as 400 Bad Request or a dropped connection.
Those runtime errors are annoying, but they are rarely fatal. HTTP is stateless, so there is no long-lived channel to corrupt. JSON schemas are validated at the message boundary, not across a multi-step conversation. The entire architecture of the web was designed to avoid the exact problem session types solve, because the web was designed for languages that could not express session types.
Session types shine in domains where protocol fidelity matters deeply: financial transaction systems, hardware control protocols, safety-critical message passing. Those domains exist, but they are not where most developers spend most of their time. For a typical web API, the overhead of encoding every endpoint interaction in a linear type system is hard to justify when OpenAPI and a few integration tests catch the same bugs with far less friction.
Distributed systems have harder problems than protocol order
Even where protocol fidelity matters, session types only solve one class of error. They guarantee that if both participants remain connected and well-behaved, the messages arrive in the right order.
They do not guarantee that the network stays up.
A session type has nothing to say about retries, timeouts, network partitions, or crash recovery. A linear type can force you to close a channel, but it cannot force the remote host to acknowledge the close before it reboots. The hard problems in distributed systems are failure modes, not happy-path ordering. Session types address the happy path with mathematical elegance, which is why they thrived in research. Production systems live in the failure modes, which is why they stayed there.
The ecosystem is finally shifting, but slowly
Rust is not the only sign of progress. Languages like Pony and the experimental Austral explicitly build linear or affine types into their core design. Academic compilers now target WebAssembly with session-typed interfaces. The IETF has even explored session-typed specifications for protocol standards.
The real breakthrough is not any single language feature. It is the slow cultural shift toward compile-time safety as a productivity tool rather than an academic luxury. When memory safety went from a C programmer’s burden to Rust’s selling point, it opened the door for other linear reasoning applications. Session types are riding that wave, but they are still near the shore.
If you want to try session types today, start with Rust and the session_types crate. It provides channel primitives with full session type checking at compile time. Here is a minimal server-client pair using the crate:
use session_types::*;
// Client sends i32, receives String, ends.
type ClientProto = Send<i32, Recv<String, Eps>>;
// Server receives i32, sends String, ends.
type ServerProto = Recv<i32, Send<String, Eps>>;
fn client(c: Chan<(), ClientProto>) {
let c = c.send(42);
let (msg, c) = c.recv();
println!("Got: {}", msg);
c.close();
}
fn server(c: Chan<(), ServerProto>) {
let (n, c) = c.recv();
let c = c.send(format!("You sent {}", n));
c.close();
}
This is real code that compiles with session_types and enforces the protocol at the type level. The limitation, as always, is that both sides must agree on the type. There is no gradual adoption for a linear protocol. You cannot session-type one microservice and leave the rest of your fleet unchecked.
Start with the type-state pattern in Rust
You do not need to adopt a research crate to get value from session types. The type-state pattern shown in the first example is a practical intermediate step. Define your protocol as a series of types, consume the state on every transition, and let the compiler catch sequence errors before they reach staging.
It is not a full session type system. It does not handle branching, recursion, or multiparty protocols. It does, however, eliminate an entire category of runtime protocol bugs with zero overhead and no external dependencies.
That is a reasonable place to start. The research papers will still be there when you are ready for the rest.