Typestate-like pattern in Java with sealed interfaces for the win!

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 stateUser.Unauthenticated and User.Authenticated are distinct types
Operations are only available on the correct state typeauthenticate only on Unauthenticated; doProtectedThing only on Authenticated
Transition functions return a value of a different typeauthenticate returns Success(Authenticated) or Failure
Impossible to have an object in an invalid state at compile timeYes, 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