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 extension
s 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.
Un logiciel (libre) de statistiques. Sous le coude, des fois que ça me serve à l'avenir. (SOFA est pédagogique dans son approche, mais sa stabilité et utilisabilité est questionnable)
Discussion à propos de futures évolutions de Shaarli.