The Typestate Pattern: Moving Checks into the Compiler
Caroline Morton
August 2, 2026
This is post 6 in my series on using Rust in data pipelines, and the series splits into two halves. Part I covered validation at the messy-data boundary - i.e. when your data gets into your data pipeline - what I would call “individual correctness” checks. That is to say a date is really a date, a number that must be positive due to your domain knowledge (like a blood pressure reading) is indeed positive, and so on. Part II is about compositional correctness: the guarantee that a combination of values is valid, not just that each value is valid in isolation. This post is the second one in Part II, and it builds directly on the builder pattern post, which was the first. You can see an overview of the whole series in the why rust for data intensive applications post.
As in the rest of series, I am going to be using the examples from health data research to illustrate the concepts, but the same issues arise in any domain where you are ingesting messy data into a pipeline. That might be finance, customer data, data that is being scraped, or any other domain where you are ingesting data that is not guaranteed to be clean and valid.
Builder pattern recap
In the last post we worked through the builder pattern for constructing HospitalEpisode instances, highlighting how it helps manage required and optional fields.
To recap the problem space quickly: a patient can go to hospital (one or more times) and each hospital visit generates a row of data called a HospitalEpisode. This row represents a single hospital visit and contains a number of fields, some of which are required and some of which are optional. The required fields are patient_id, admission_date, and admission_method. The optional fields are primary_diagnosis, secondary_diagnoses, discharge_date, and discharge_method. Since we are talking about compositional correctness, by the time we get to this part in the data pipeline, we have already validated that these data fields are valid in isolation (e.g. the admission_date is a valid date, the patient_id is a valid ID, etc.). We can represent the HospitalEpisode struct in Rust as follows:
struct HospitalEpisode {
patient_id: String,
admission_date: NaiveDate,
admission_method: AdmissionMethod,
primary_diagnosis: Option<IcdCode>,
secondary_diagnoses: Vec<IcdCode>,
discharge_date: Option<NaiveDate>,
discharge_method: Option<DischargeMethod>,
}
Our IcdCode is a newtype around a String that validates that the string is a valid ICD-10 code, and our AdmissionMethod and DischargeMethod are enums that validate that the value is one of the allowed values for those fields. You can read more about how I like to use newtypes to validate individual fields in the second post in this series.
What we are now concerned with is whether the combination of values makes sense. For example, that when a patient is discharged, the discharge_date, discharge_method and primary_diagnosis fields are present. If a patient is still in hospital, then discharge_date and discharge_method should be absent, but primary_diagnosis might be present or absent.
In our last post we used the builder pattern to manage the construction of HospitalEpisode instances using these rules. You can check out the specific code here but in summary, the builder pattern allowed us to construct a HospitalEpisode instance step by step, setting required fields and optional fields as needed. This involved a new struct called a HospitalEpisodeBuilder that had methods for setting each field, and a build() method that would check that all required fields were present and return a Result<HospitalEpisode, DataError>, where DataError would indicate if any required fields were missing.
let ongoing_episode = HospitalEpisodeBuilder::new()
.patient_id("12345".to_string())
.admission_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.admission_method(AdmissionMethod::new("Emergency")?)
.primary_diagnosis(IcdCode::new("A00")?)
.secondary_diagnosis(IcdCode::new("B00")?)
.secondary_diagnosis(IcdCode::new("C00")?)
.build();
In this code example, we are constructing an ongoing HospitalEpisode using the builder pattern. We were taking advantage of Rust’s type system and the match statement to ensure that we only constructed valid HospitalEpisode instances, but there is a gap: the check for required fields was still happening at runtime.
In our example, the build() method would return an error if any required fields were missing, but this check was deferred until runtime. If we forgot to set the required field of patient_id, the code would compile just fine, but when we called build(), it would return Err(DataError::MissingField("patient_id".into())):
// Nothing stops you from omitting a required field.
let missing_patient_id = HospitalEpisodeBuilder::new()
// .patient_id(...) <-- omitted, but the compiler doesn't care
.admission_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.admission_method(AdmissionMethod::new("Emergency")?)
.build();
match missing_patient_id {
Ok(episode) => println!("built: {}", episode.to_csv_row()),
Err(reason) => eprintln!("\nruntime failure: {reason}"),
}
This is where the typestate pattern comes in - it allows us to move this check into the compiler, so that if a required field is missing, the code will not compile at all.
Before we get into the mechanics of the typestate pattern, let’s outline the goals and constraints: We want to make sure that always-required fields are present at compile time. The basic thought here is why wait until runtime to check for something that we know we always need.
What is the typestate pattern
The typestate pattern is a design pattern that allows us to encode an object’s state in its type, so that only the operations valid in that state are available. This means that there is a kind of state machine that lives in the type parameters. Illegal transitions are pushed up to become type errors, rather than runtime errors.
That is the textbook sentence, but like many things in coding, it can be dense and difficult to understand if you aren’t coming from a computer science background. What is a state machine? What is a state? What does it mean to encode a state in a type? And what does it mean to push an illegal transition up to become a type error?
Let’s start with state - “state” is one of those words that gets talked about at a high level and then the assumption is that everyone just knows about it. If you have never written a state machine, or never use the word state in your day-to-day programming, I can assure you that you are still using the mental model of it all the time, whether you realise it or not.
Put simply a state is just a fact about an object at a particular moment in time. It is a fact that governs what you are allowed to do with that object next. A file can be open or closed. If it is open you can call read() on it; if it is closed you cannot. The state governs which operations are allowed. The “machine” half is the set of rules for moving between those states: calling open() takes you from closed to open, and calling close() takes you back again. That is all a state machine is: a set of states, the transitions between them, and the rules about which operations are allowed in which state.
Now let’s think about where the state actually lives. In most code it lives in the value itself, at runtime. The file handle has a flag inside it somewhere that records whether it is open, and when you call read() the function looks at that flag and decides whether to give you data or an error. The compiler is not part of that decision. As far as the compiler is concerned there is one type, File, and read() is a perfectly reasonable thing to call on it. It will let you write the call, compile the program and ship it, and you find out you were wrong when the code runs and the error comes back at runtime. That is all a runtime error is - an error that you only find out about when the code runs. We have stated above that our aim is to push that check into the compiler, so that if you try to read from a closed file, the code will not compile at all. That is what the typestate pattern allows us to do.
This is exactly where we were at the end of the last post. Whether patient_id had been set or not is a state. But that state lived inside the builder at runtime, so the compiler could not see it, and build() had to go and look for itself and hand us back a Result.
So we have a rule - you cannot read from a closed file, you cannot build an episode without a patient id - and we have two ways of enforcing it:
- We can check the rule when the code runs
- We can arrange things so that breaking the rule is not possible in the first place.
Option 1 is the builder pattern from the last post. Option 2 is the typestate pattern, and it is what the rest of this post is about.
A checklist vs a thing that does not fit
I find this easiest to explain with something from my old job. I only did about 8 months of surgical (or obstetric) training in all my years as a doctor, but I remember the safety culture very clearly.
Before a surgical operation starts, the theatre team runs the WHO surgical safety checklist. The first part, the “sign in”, happens before the patient is anaesthetised and confirms a handful of separate things: the patient has confirmed their identity and what they are having done, the surgical site is marked (left/right), the anaesthetic machine has been checked, known allergies have been asked about. These items are independent of one another and it does not particularly matter which order you do them in. What matters is that the operation does not start until all of them are done. So how do you enforce that?

Well the first way is the one most of us are used to. You work through the list, and at the end somebody asks “have we done everything?” If something was missed, that is the moment you find out. This is the builder pattern. All the fields go in one by one, and build() asks at the end whether anything is missing. It works, and it is enormously better than not asking at all. But the check happens at the last possible moment, and by then the patient is already on the table. If they were the wrong patient, or the wrong side was marked, or the anaesthetic machine was not checked, it has already wasted the valuable resources of the theatre team and the time of the patient.
The second way shows up in the parts of medicine where getting it wrong is unrecoverable, and it does not look like a checklist at all. In anaesthetics, doctors have to use different types of gas cylinders to deliver oxygen, nitrous oxide, and other gases. The problem is that all of these bottles are similar in size and shape - or at least they used to be in the past. If you give nitrous oxide to a patient thinking it is oxygen, the patient will die. You can colour-code the cylinders, you can label them, you can put a checklist in front of the anaesthetist and ask them to confirm that they have the right gas. But if they are tired, or distracted, or just human, they will get it wrong occasionally.
So designers thought about how to make it impossible to get wrong. They changed the yoke (or pins) on the anaesthetic machine so that the nitrous oxide cylinder physically cannot fit into the oxygen yoke. This is not “should not be” - it is cannot ever be. The shape of the equipment makes the wrong answer impossible to express. It does not physically fit. There is no checklist item for it, and no moment where a tired person at the end of a long list confirms it was done correctly.
That is the typestate pattern: it does not involve writing a better check (or checklist); it is about changing the shape of the thing so that the mistake cannot be expressed in the first place.
Back to the code
In our case, “state” means: which of the required fields have we been given so far? An empty builder is in one state. A builder that has been handed a patient_id is in another. A builder that has been handed all three required fields is in a third, and that third state is the only one where build() is allowed.
The move is to make those different types. A builder that has not been given a patient_id yet is a different type from one that has, and build() exists only on the fully-populated type. Not “returns an error if you call it too early” - it does not exist. There is no method there to call. The pins do not line up.
That is what the definition at the top of this section means by a state machine living in the type parameters. The states are which required fields we have supplied, the transitions are the setter methods, and the illegal transition - trying to finish before you have everything - stops being a runtime error you have to remember to handle and becomes a compile error, which means the program never runs at all.
If that sounds like a lot of machinery, it ends up being only three moving parts, and we are going to add them one at a time. The first part is the markers, the second part is the type parameters, and the third part is the methods. Each one is small and simple. The end result is something that looks very similar to the builder pattern we wrote in the last post, but with a compile-time guarantee that you cannot build an episode without all three required fields.
The markers
The first part is the smallest one. We need two types whose only job is to be different from each other:
pub struct Missing;
pub struct Present;
If you have not come across this form before, these are unit structs - structs with no fields at all. There is no data inside them, there are no methods on them, and we are never going to create one. Missing is not a value that gets passed around. It is just a name. The reason we want two of them is that the type system can tell two types apart. So if we hand it two types called Missing and Present, we can use which of the two appears in the builder’s type as our record of whether a field has been supplied yet.
They are also free. A struct with no fields has a size of zero bytes, so the markers do not exist in the compiled program at all. Everything we are about to build out of them happens while the code is being compiled, and by the time it runs there is nothing left of it.
One note on the names. When we get the compiler to reject a bad builder later in this post, the error message prints the marker names straight out:
HospitalEpisodeBuilder<Missing, Present, Present>
Anyone can read that and see which field they forgot, without knowing anything about how the builder is put together. If I had called them A and B, or S0 and S1, the compiler would be just as happy and the person reading the error would be none the wiser. Like with many things I have talked about in this series, the key here is to make the code readable for humans and not just the compiler.
The type parameters
The second part is where these markers go. The builder gains three type parameters, one for each of the always-required fields:
pub struct HospitalEpisodeBuilder<Pid, Adm, Meth> {
patient_id: Option<String>,
admission_date: Option<NaiveDate>,
admission_method: Option<AdmissionMethod>,
primary_diagnosis: Option<IcdCode>,
secondary_diagnoses: Vec<IcdCode>,
discharge_date: Option<NaiveDate>,
discharge_method: Option<DischargeMethod>,
_state: PhantomData<(Pid, Adm, Meth)>,
}
Pid, Adm and Meth stand for patient_id, admission_date and admission_method. Each one is going to be either Missing or Present, and it is the position that tells you which field is being talked about, so HospitalEpisodeBuilder<Present, Missing, Missing> is a builder that has been given a patient id and nothing else. The other four fields do not get a parameter, because they are optional so there is nothing to enforce about them. The typestate pattern is only for required fields, and optional fields stay as they were. We are not trying to make a builder that is more strict with optional fields - we are trying to make a builder that won’t work if you leave out a required field, which is almost certainly a bug, rather than a valid use case. Optional fields are optional for a reason, and the typestate pattern is not about changing that.
If you look at the fields themselves, nothing has changed from our original HospitalEpisodeBuilder in the previous post: patient_id is still an Option<String> and it still starts out as None. So we now have two separate records of whether a field has been supplied. The Option records it at runtime by being Some or None, which is the same as it ever was. The type parameter records it at compile time by being Present or Missing. Rust does not connect those two for us, and nothing in the language stops the type from claiming Present while the Option underneath is still None. What keeps them in step is that the only way to get a Present is to call the setter, and the setter is also the thing that fills in the Option. We will write those setters in the next section, and once they exist build() can take the three required fields out of their Options without checking them.
That leaves _state, which looks a bit off but refers directly to the concepts of state that we talked about above. PhantomData is a marker type from the standard library that holds no value and takes up no space, and it is there to satisfy a rule: Rust requires every type parameter on a struct to be used by that struct somewhere. Pid, Adm and Meth never appear in any of the real fields, because there is no data associated with them, so without PhantomData the compiler rejects the struct outright:
error[E0392]: type parameter `Pid` is never used
--> src/episode.rs:89:35
|
89 | pub struct HospitalEpisodeBuilder<Pid, Adm, Meth> {
| ^^^ unused type parameter
|
= help: consider removing `Pid`, referring to it in a field, or using a marker such as `PhantomData`
The help line gives the fix. PhantomData<(Pid, Adm, Meth)> mentions all three parameters in a single field, which is enough to satisfy the rule, and the tuple is just a convenient way to name three things at once.
The methods
The third part is the one that enforces our rules, and it works by splitting the builder’s methods across three separate impl blocks rather than putting them all in one. Which block a method lives in is what decides the states it is available in.
new() goes in a block written for the empty state:
impl HospitalEpisodeBuilder<Missing, Missing, Missing> {
pub fn new() -> Self {
HospitalEpisodeBuilder {
patient_id: None,
admission_date: None,
admission_method: None,
primary_diagnosis: None,
secondary_diagnoses: Vec::new(),
discharge_date: None,
discharge_method: None,
_state: PhantomData,
}
}
}
This is the same as the new() from the last post, except that it is now only available on one concrete type which is HospitalEpisodeBuilder<Missing, Missing, Missing>. Every builder starts life with all three markers set to Missing, and there is no way to skip ahead.
The setters for the required fields go in a generic block, because you are allowed to set patient_id whatever state you happen to be in:
impl<Pid, Adm, Meth> HospitalEpisodeBuilder<Pid, Adm, Meth> {
pub fn patient_id(self, patient_id: String) -> HospitalEpisodeBuilder<Present, Adm, Meth> {
HospitalEpisodeBuilder {
patient_id: Some(patient_id),
admission_date: self.admission_date,
admission_method: self.admission_method,
primary_diagnosis: self.primary_diagnosis,
secondary_diagnoses: self.secondary_diagnoses,
discharge_date: self.discharge_date,
discharge_method: self.discharge_method,
_state: PhantomData,
}
}
}
Notice here, we are not using mut self like we did in the last post. The return type is instead a new HospitalEpisodeBuilder carrying the Present marker for patient_id. This means that when we set the patient_id, Pid is now Present, while Adm and Meth remain whatever they were before. This is the transition that moves us along the state machine. admission_date() and admission_method() are the same method with the Present in a different slot, so I have not printed them here, but they are in the repo if you want to read them.
The self in that signature is doing something different from the last post. There the setter was fn patient_id(mut self, patient_id: String) -> Self, which took the builder, changed one field and handed the same builder back. That is not possible now, because the builder coming out is a different type from the one that went in, and you cannot mutate a value into a different type. So the method takes ownership of the old builder, moves all seven fields across into a new one, and returns that instead. This is where the verbosity of a hand-written typestate builder comes from, and there is no way around it - three required fields means three setters, each of which lists every field on the struct.
The optional setters stay in that same generic block and are unchanged from the last post:
pub fn primary_diagnosis(mut self, primary_diagnosis: IcdCode) -> Self {
self.primary_diagnosis = Some(primary_diagnosis);
self
}
They return Self, so the markers come back exactly as they went in and setting a primary diagnosis does not move you along at all. Just to reiterate this from the last section, the required fields have a transition, the optional ones do not, and an optional setter is available in every state because there is nothing about it to enforce - it’s optional!
Our build method is the last piece, and it is only implemented on the fully-populated type:
impl HospitalEpisodeBuilder<Present, Present, Present> {
pub fn build(self) -> Result<HospitalEpisode, DataError> {
let patient_id = self.patient_id.expect("patient_id: guaranteed by typestate");
let admission_date = self.admission_date.expect("admission_date: guaranteed by typestate");
let admission_method = self.admission_method.expect("admission_method: guaranteed by typestate");
// The conditional discharge rules from the last post, unchanged.
// ...
}
}
build() is now only available on one concrete type, HospitalEpisodeBuilder<Present, Present, Present> - that is to say the builder that has been given all three required fields. If you try to call build() on a builder that is missing any of those fields, the compiler rejects it outright, and we will look at exactly what it says in a moment.
The eagle-eyed will notice that I have introduced expect calls here. We previously had .ok_or(DataError::MissingField("patient_id".into()))? and the like in the body of build(), but now we are unwrapping the Options directly. Why? Doesn’t this directly contradict your opinions on not using panic in production code? It does, and I will explain why it is appropriate here. Firstly we need to deal with the fact that the Options are still there - so we need to get at the inner value. Rather than gracefully returning an error, we should panic if we are wrong, because that is a bug in the code. Ending up with a None as a patient_id when the builder is in the <Present, Present, Present> state is impossible if the code is correct, and so this is what I would call an unrecoverable error. The code is wrong, and the only way to fix it is to fix the code. There is no way to recover from this error at runtime, so we should panic and let the programmer know that they have made a mistake.
In the errors post I said that thinking you know better than the compiler is where the dragons are, so it is fair to ask whether this is one of those cases. It is not, because the compiler has done the hard part and proved we are in the <Present, Present, Present> state. What it has not checked is that a Present marker always means the matching Option is Some. That part is down to how the builder is written. The fields are private, so nobody outside this file can construct one directly, and the only way to get a builder at all is new(), which sets all three markers to Missing. From there, the only way to turn a Missing into a Present is to call the setter, and the setter puts the value into the Option at the same time. A Present marker and a filled-in Option always arrive together.
build() still returns a Result regardless, because the conditional rules are still there. Whether an episode is ongoing or discharged is a fact about the data rather than about the order you called the setters in, so the match on the discharge fields from the last post is still sitting in there doing its job. The typestate has removed the unrecoverable kind of error and left the recoverable kind to build().
Leaving out a required field
The valid chain is identical to the last post:
let discharged_episode = HospitalEpisodeBuilder::new()
.patient_id("12345".to_string())
.admission_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.admission_method(AdmissionMethod::new("Emergency")?)
.primary_diagnosis(IcdCode::new("A00")?)
.secondary_diagnosis(IcdCode::new("B00")?)
.discharge_date(NaiveDate::from_ymd_opt(2026, 1, 10).unwrap())
.discharge_method(DischargeMethod::new("Home")?)
.build()?;
None of the machinery from the last section shows up at the call site. It compiles and runs just as it did before, and you wouldn’t necessarily know that the typestate is there at all, because it is doing its work under the hood of the HospitalEpisodeBuilder type. The only difference is that if you leave out a required field, the compiler will reject it instead of letting it run and returning an Err. Let’s leave out the patient_id and see what happens:
let missing_patient_id = HospitalEpisodeBuilder::new()
// .patient_id(...) <-- omitted
.admission_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.admission_method(AdmissionMethod::new("Emergency")?)
.build();
Last time this compiled quite happily and handed back Err(DataError::MissingField("patient_id".into())) when it ran. Now cargo build does not produce a binary at all:
error[E0599]: no method named `build` found for struct `HospitalEpisodeBuilder<Missing, Present, Present>` in the current scope
|
89 | pub struct HospitalEpisodeBuilder<Pid, Adm, Meth> {
| ------------------------------------------------- method `build` not found for this struct
|
= note: the method was found for
- `HospitalEpisodeBuilder<Present, Present, Present>`
This is where I love Rust’s compiler errors. Look at the note at the bottom. It is telling you exactly what is wrong, and it is telling you in terms of the state machine we have just built. It is saying “you are in the <Missing, Present, Present> state, and you are trying to call build(), but that method only exists on <Present, Present, Present>”. It is telling you exactly what you need to do to fix it: set the patient_id before calling build().
Code errors and data errors
Everything the typestate does happens while the code is being compiled and it can only catch mistakes that have been coded into the program. It cannot catch mistakes in the data, because the data is not available until the program runs. Where typestate excels in data pipelines is in the code that constructs a record from something other than a row of a file. You can see why by looking at the pipeline:
pub fn into_episode(self) -> Result<HospitalEpisode, DataError> {
let mut builder = HospitalEpisodeBuilder::new()
.patient_id(self.patient_id)
.admission_date(self.admission_date)
.admission_method(self.admission_method);
// optional and conditional fields added here
builder.build()
}
The three required fields come out of a ValidatedEpisode, where they are plain values rather than Options. This chain always sets all three, always arrives at <Present, Present, Present>, and the typestate never gets an opportunity to reject anything.
The reason those fields are plain values is that a missing patient_id was already dealt with upstream. If the column is not in the row then serde refuses to deserialise it, and the loop in main collects that as a rejected row and carries on with the next one:
3 accepted, 5 rejected
rejected: CSV read error: CSV deserialize error: record 8 (line: 9, byte: 439): unknown variant `teleport`, expected `emergency` or `elective`
rejected: inconsistent fields: discharge_date set without discharge_method
This is the recoverable case from part one of the errors series. One bad row is not a reason to stop processing the rest of the file, so it becomes an Err, gets recorded, and the run continues.
That is what makes the expect calls in build() safe. A missing patient id in the data cannot reach them, because the data path was handled at the boundary and that row never got this far. The only way to arrive at build() holding a None is for somebody to have written the wrong code, which is a bug rather than a bad row, and a bug is exactly the case where you want the program to stop instead of carrying on.
The places that do benefit are the ones where a person types the chain out: the examples in main, test fixtures, and anywhere an episode is assembled in code rather than read from a file. That is where a setter can be forgotten.
So the two patterns are not in competition and the builder post has not been superseded. Reading messy data in is a runtime problem and it needs runtime tools - serde and newtypes for individual correctness, and a build() that returns a Result for the combination rules. Writing code that constructs a record is a compile-time problem, and the typestate is the tool for that. Both of them live in the same build(), doing different jobs.
DataError::MissingField has gone from the error enum altogether. In the last post it was the variant build() returned when a required field was absent, and now there is no code path left that could produce it, so I deleted it:
#[derive(Debug, Error)]
pub enum DataError {
#[error("CSV read error: {0}")]
Csv(#[from] csv::Error),
#[error("inconsistent fields: {0}")]
InconsistentFields(String),
#[error("invalid value: {0}")]
InvalidValue(String),
}
Trade-offs
Three required fields means three setters, and each one has to list all seven fields on the struct because it constructs a new builder rather than modifying the old one. A fourth required field would mean a fourth type parameter, a fourth setter, and another line inside each of the setters that already exist. There are crates that generate all of this from a derive macro, which would be the better option once you are past a handful of required fields, but I have not used them much myself so I am not going to tell you which one to pick.
A required field also has to be set unconditionally. Each setter returns a different type, so you cannot put one inside an if:
let builder = HospitalEpisodeBuilder::new()
.admission_date(admission_date)
.admission_method(admission_method);
let builder = if have_patient_id {
builder.patient_id(id)
} else {
builder
};
error[E0308]: `if` and `else` have incompatible types
|
56 | builder.patient_id("12345".to_string())
| --------------------------------------- expected because of this
57 | } else {
58 | builder
| ^^^^^^^ expected `Present`, found `Missing`
|
= note: expected struct `HospitalEpisodeBuilder<Present, _, _>`
found struct `HospitalEpisodeBuilder<Missing, _, _>`
The two branches produce different types and there is nothing that can unify them. The same goes for setting a required field inside a loop, or in one arm of a match, or in a helper function that hands back a half-built builder - for that last one you would have to write HospitalEpisodeBuilder<Present, Missing, Missing> out as the return type, and change it every time the helper changed.
In practice this lines up with what the pattern is for. If you are branching on whether you have a patient id, then you do not know at compile time whether you have one, and the premise the typestate rests on has gone. That is a data question and it belongs at the boundary with a Result. Our pipeline gets away with an unconditional chain because the validation stage already guaranteed the three fields were there.
So the machinery is worth it when the required fields are always required and always available at the point of construction, when a wrong record is expensive, and when the builder is called from a lot of places. A library API is the clearest case, because it is written once and every caller gets the benefit.
It is not worth it for a record that is mostly optional fields, for a prototype, or where a runtime error is a perfectly good outcome that the caller can deal with. It is also not worth it for any rule that depends on the data, which is the mistake I would most expect someone to make after reading a post like this one. The discharge rules in build() are a good example of where you can get caught out here: they look like they ought to be liftable into the type system but they are not, because whether an episode has been discharged is not known until you look at the row. If there is a problem where the composition of the row’s discharge data is inconsistent, that is a data error and it belongs as a runtime error, not a compile-time error.
Wrapping up
In this series I have been coming at correctness from two directions - individual correctness and compositional correctness. Serde and the newtypes from Part I check that each value is what it claims to be, one field at a time. The builder pattern went up a level and checked the combination, which is where the discharge rules live. This post has taken one piece of that combination check - whether the always-required fields are there at all - and moved it out of runtime and into the compiler.
The strictness of the check has not changed between the builder and typestate examples. The builder was already strict and it returned an Err if patient_id was missing. What has changed is when you find out. A Result has to be handled at every call site, for as long as the code exists. If the method is not there at all then there is nothing to handle, because the code containing the mistake never becomes a program. The evidence is that I was able to delete DataError::MissingField from the error enum: there is no code path left that could produce it.
That is the gas cylinder again: the checklist works, and a good team running it catches almost everything, but the pin index catches everything because the wrong answer cannot be expressed. The phrase people use for this is making illegal states unrepresentable, and the typestate pattern is one fairly small instance of it.
One thing that can be confusing is when to use the typestate pattern at all. The basic rule is that it catches code errors, not data errors. The compiler only knows about the code you wrote, so nothing here says anything about the contents of a file, and a field that is only sometimes available is not a required field. Everything that depends on the data - the discharge rules, the admission dates in the future, the ICD codes that turn out not to be ICD codes - still needs runtime validation and still comes back as a Result.
All the code for this post is in the typestate directory of the series repo, sitting next to the builderpattern version from last time if you want to compare the two.
This post relied on the required fields always being required, and the typestate has nothing to say about a field that is only sometimes there - like discharge_method. None might mean the patient has not been discharged yet, or that they were discharged and nobody wrote down how, or that the value was suppressed before the extract reached us. Option gives you no way to tell them apart, even though you would count them differently. The next post is about modelling missingness, so that the reason a value is absent survives into the pipeline.
If you liked this post, here are the others it connects to:
- The Builder Pattern for Complex Records - the post this one is a direct sequel to, where
build()still checked the required fields at runtime. - Error Handling in Rust: anyhow and thiserror - typed errors with
thiserror, which is whereDataErrorcomes from, and where I said that thinking you know better than the compiler is where the dragons are. - Error Handling in Rust: Fundamentals - the recoverable and unrecoverable distinction that the
expectcalls inbuild()rest on. - Serde Rust: Data Serialisation for Data Scientists - the first stage of the pipeline, where a bad row is rejected long before the builder sees it.
- Why Use Newtypes? Encoding Domain Knowledge in the Type System -
IcdCode,AdmissionMethodand the rest of the individual-correctness layer. - The Single Responsibility Principle for Scientists Who Write Code - why deserialise, validate and compose are three separate stages rather than one large function. The examples in that one are in Python, but the principles are the same - it is about where the responsibility for a piece of work belongs.