The Typestate pattern is a very interesting design approach that encodes an object’s state within its type, allowing certain operations to be restricted based on that state. This helps catch errors at compile-time rather than runtime, improving code safety and efficiency.
In Java it’s not possible to implement it fully, as Java does not provide a mechanism to enforce move semantics, however many of the characteristics of TypeState can be achieved, as seen in the table at the end of this article.
Now, let’s think of a scenario where a user can be either Unauthenticated or Authenticated.
Further, the Unauthenticated user can perform an operation called “authenticate”, and if that operation is successful then we have access to an instance of an Authenticated user.
The most important part of this model of thinking is that the absolutely ONLY way to get an instance of an Authenticated user is via going through the authenticate method.
We can model this in Java using sealed interfaces, which were added in Java 17.
// Interface is sealed, nobody else can implement it
public sealed interface User
permits User.Unauthenticated, User.Authenticated {
// Unauthenticated user – you start here
final class Unauthenticated implements User {
public AuthResult authenticate(
String username,
String password) {
if (username.equals("ramm") && password.equals("stein")) {
return new AuthResult.Success(new Authenticated(username));
} else {
return new AuthResult.Failure("Invalid credentials");
}
}
}
// Authenticated user
// can ONLY be created via the method above
final class Authenticated implements User {
private final String username;
// private constructor blocks instantiation
// outside of this file
private Authenticated(String username) {
this.username = username;
}
// TODO add code to block
// binary serialization, Jackson or JPA as needed
public String doProtectedThing() {
return "Welcome " + username + ", here is your secret data 🔐";
}
public User logout() {
return new Unauthenticated();
}
}
// 2. The result type – also sealed, so you must handle both cases
// I very much would rather do this than throwing Exceptions
sealed interface AuthResult
permits AuthResult.Success, AuthResult.Failure {
record Success(User.Authenticated user) implements AuthResult {}
record Failure(String reason) implements AuthResult {}
}
}
Now we can use it as follows:
void main() {
User.Unauthenticated user = new User.Unauthenticated();
var result = user.authenticate("ramm", "stein");
// does not compile, we cannot instantiate User.Authenticated
//User.Authenticated aut = new User.Authenticated("bla");
// The compiler FORCES you to handle both success and failure
String message = switch (result) {
// Only if successful we have access to an instance of Authenticated and can run doProtectedThing
case User.AuthResult.Success(var authenticated) -> authenticated.doProtectedThing();
case User.AuthResult.Failure(var reason) -> "Login failed: " + reason;
};
IO.println(message);
}
Here’s the list of characteristics that can be achieved in Java.
| Criterion (strict Typestate) | This Code |
| Distinct types for each state | User.Unauthenticated and User.Authenticated are distinct types |
| Operations are only available on the correct state type | authenticate only on Unauthenticated; doProtectedThing only on Authenticated |
| Transition functions return a value of a different type | authenticate returns Success(Authenticated) or Failure |
| Impossible to have an object in an invalid state at compile time | Yes, because Authenticated is package-private and only constructed via the transition |
| No runtime state field that is later mutated to change “mode” | There is no mutable field that switches the object from one state to another; each object is immutable and belongs to exactly one state for its entire lifetime |

Leave a comment