The Daily Shaarli

All links of one day in a single page.

Today - July 30, 2024

The TypeState pattern in Dart using sealed classes

One thing that Rust is great for is using the type system to encode states. With the (relatively) new sealed classes in Dart, it's possible to build something similar, relying on the type system to prevent any misuse of the API.

As an example, I'll create a robust HTTP response interface. This approach enforces valid state transitions while providing a clear and concise API for constructing HTTP responses. The implementation ensures the correct order of operations when setting the HTTP status code (which must be first), adding headers (from zero to any number, after the HTTP status, but before the response body), and defining the body of the response (which is the last thing allowed to do with the response).

Implementation

Here is the code, without syntax highlighting. Sorry about that, but you can actually copy-paste it to DartPad to make it pretty and run it! I also assume you have a window that is more than 80 characters wide, so I've longer lines here.

/// Represents the base class for HTTP response states.
sealed class HttpResponse {
  const HttpResponse();
}

/// Represents the initial state of an HTTP response.
class HttpResponseInitial extends HttpResponse {
  const HttpResponseInitial();

  /// Sets the status code and transitions to the next state.
  HttpResponseAfterStatus status(int code, String message) => HttpResponseAfterStatus(code, message, this);
}

/// Represents the state of an HTTP response after a status code has been set.
class HttpResponseAfterStatus extends HttpResponse {
  /// The HTTP status code.
  final int statusCode;

  /// The HTTP status message.
  final String statusMessage;

  /// The list of headers associated with the response.
  final List<Map<String, String>> headers;

  /// Creates an instance of [HttpResponseAfterStatus].
  HttpResponseAfterStatus(this.statusCode, this.statusMessage, HttpResponse previous) : headers = previous is HttpResponseAfterStatus ? previous.headers : [];

  /// Adds a header to the response, creating a new object.
  HttpResponseAfterStatus header(String key, String value) => HttpResponseAfterStatus(statusCode, statusMessage, this)..headers.add({key: value});

  /// Adds a header to the response, mutating the current object.
  HttpResponseAfterStatus header2(String key, String value) => this..headers.add({key: value});

  /// Sets the body of the response and finalizes it.
  void body(String text) {
    print('Response: $statusCode $statusMessage');
    headers.forEach((header) => header.forEach((key, value) => print('$key: $value')));
    print('Body: $text');
  }
}

void main() {
  // Example of a valid response
  HttpResponseInitial()
      .status(200, "OK")
      .header("X-Unexpected", "Spanish-Inquisition")
      .header2("Content-Length", "6")
      .body("Hello!");

  // Example of an invalid response (uncommenting will cause a compile-time error)
  /*
  HttpResponseInitial()
      .header("X-Unexpected", "Spanish-Inquisition"); // Error: statusCode not called

  HttpResponseInitial()
      .statusCode(200, "OK")
      .body("Hello!")
      .header("X-Unexpected", "Spanish-Inquisition"); // Error: header called after body
  */
}

 Explanation

In this implementation, a sealed class HttpResponse serves as the base for two subclasses: HttpResponseInitial and HttpResponseAfterStatus. The HttpResponseInitial class represents the initial state of the response, while HttpResponseAfterStatus captures the state after the HTTP status code has been set. The status method transitions from the initial state to the after-status state, allowing us to set the HTTP status code and message.

The header method creates a new instance of HttpResponseAfterStatus while adding a new header to the existing list of headers. This ensures that headers are not duplicated. Additionally, the header2 method allows for mutating the current instance by directly adding a header to the existing list. The two approaches are functionally identical, but there might be a runtime performance of recreating a new object for each header.

Finally, the body method finalizes the response by printing the status, headers, and body content.

This design enforces the correct order of operations, preventing invalid states such as adding headers before setting the status code or after defining the body.

Variation: State Type Parameter

Instead of having separate classes for each state, the state can be modeled as a type parameter for a single generic class. This approach reduces boilerplate and enhances flexibility, but it may be more complex to understand initially.

Consider the HTTP response example again. Here's how it can be modeled it using a state type parameter, using extensions to define operations that are valid only in specific states.

/// Represents the base class for HTTP response states.
sealed class ResponseState {}

/// Represents the initial state of an HTTP response.
class Start implements ResponseState {}

/// Represents the state of an HTTP response after headers have been set.
class Headers implements ResponseState {}

/// Represents the HTTP response with a state type parameter.
class HttpResponse<S extends ResponseState> {
  final int? statusCode;
  final String? statusMessage;
  final List<Map<String, String>> headers;

  HttpResponse({this.statusCode, this.statusMessage, List<Map<String, String>>? headers}) : headers = headers ?? [];

  /// Creates a new response in the Start state.
  static HttpResponse<Start> create() => HttpResponse<Start>();
}

/// Operations valid only in Start state.
extension StartOperations on HttpResponse<Start> {
  HttpResponse<Headers> status(int code, String message) => HttpResponse<Headers>(statusCode: code, statusMessage: message);
}

/// Operations valid only in Headers state.
extension HeadersOperations on HttpResponse<Headers> {
  /// Adds a header to the response, creating a new object.
  HttpResponse<Headers> header(String key, String value) => HttpResponse<Headers>(statusCode: statusCode, statusMessage: statusMessage, headers: headers..add({key: value}));

  /// Adds a header to the response, mutating the current object.
  HttpResponse<Headers> header2(String key, String value) => this..headers.add({key: value});

  void body(String text) {
    print('Response: $statusCode $statusMessage');
    headers.forEach((header) => header.forEach((key, value) => print('$key: $value')));
    print('Body: $text');
  }
}

void main() {
  // Example of a valid response
  HttpResponse.create() // Should even be directly using `status()` to create the object.
      .status(200, "OK") // This will return HttpResponse<Headers>
      .header("X-Unexpected", "Spanish-Inquisition")
      .header2("Content-Length", "6")
      .body("Hello!");

  // Example of an invalid response (uncommenting will cause a compile-time error)
  /*
  HttpResponse.create()
      .header("X-Unexpected", "Spanish-Inquisition"); // Error: status not called

  HttpResponse.create()
      .status(200, "OK")
      .body("Hello!")
      .header("X-Unexpected", "Spanish-Inquisition"); // Error: header called after body
  */
}

Using state type parameters provides several advantages:

  • Conciseness: It reduces the number of classes needed to represent different states, making the codebase cleaner and easier to maintain.
  • Single Documentation: All operations for the HttpResponse class are documented in one place, making it easier for users to understand the available methods and their valid contexts.
  • Flexibility: Adding new operations that are valid in specific states or across multiple states becomes straightforward. For example, we can define operations that are valid in any state without needing to modify the existing structure:
    /// Operations available in any state.
    extension CommonOperations<S extends ResponseState> on HttpResponse<S> {
    int? get statusCode => this.statusCode;
    }

Unfortunately, I could not find a way to make the response creation as const.

Variation: state types that contain actual state

I tried to twist my code in a way to also implement the Variation: state types that contain actual state section of the article linked below, but without luck. I might be missing something.

Source

Inspiration drawn from The Typestate Pattern in Rust - Cliffle which is about Rust instead.