Fluid APIs in Dart: Leveraging Extension Methods for Cleaner Builders
Why extensions matter
Extension methods let you add functionality to existing types without subclassing or modifying the original library. In Dart they are resolved at compile time, so there is no runtime overhead. I reach for them whenever I want a fluent, readable API on top of a class I cannot change — think HTTP clients, JSON parsers, or third‑party model objects.
Real‑world scenario: building a request builder
Our team maintains a thin wrapper around http.Client. The wrapper exposes a Request class that holds method, URL, headers, and body. Constructing a request with many optional fields used to look like a wall of setters:
final req = Request('GET', Uri.parse('https://api.example.com/items'));
req.headers['Accept'] = 'application/json';
req.headers['Authorization'] = 'Bearer $token';
req.queryParameters['page'] = '2';
req.queryParameters['limit'] = '50';
final response = await client.send(req);
That style is error‑prone (easy to forget a header) and noisy. An extension that returns this after each mutation turns the code into a single chain.
Implementation
extension RequestBuilder on Request {
/// Adds a header and returns the request for chaining.
Request header(String key, String value) {
headers[key] = value;
return this;
}
/// Adds multiple headers at once.
Request headers(Map map) {
headers.addAll(map);
return this;
}
/// Sets a query parameter.
Request query(String key, String value) {
queryParameters[key] = value;
return this;
}
/// Sets the request body and the appropriate content‑type header.
Request jsonBody(Object body) {
this.body = jsonEncode(body);
return header('Content-Type', 'application/json');
}
/// Sends the request using the supplied client.
Future send(Client client) => client.send(this);
}
Notice each method returns Request. Because the extension is defined on Request, the compiler treats the call as if the method existed on the class itself. The jsonBody helper also sets the header, reducing duplication.
Testing the extension
Unit tests stay simple — just verify the mutated fields:
test('RequestBuilder adds headers and query params', () {
final req = Request('POST', Uri.parse('https://api.example.com'))
.header('X-Custom', 'value')
.query('filter', 'active');
expect(req.headers['X-Custom'], 'value');
expect(req.queryParameters['filter'], 'active');
});
Because the extension does not introduce new state, there is no need for mocking or integration scaffolding.
Pitfalls and best practices
- Name collisions: If two extensions declare the same method name on the same type, the compiler picks the one in scope. Keep extension names unique and import them explicitly.
- Mutability: The example mutates the original
Request. If you need immutability, return a new instance instead (e.g., using copy‑with pattern). - Generics: Extensions can be generic, which is handy for collection helpers:
extension.on List { List unique() => toSet().toList(); }
Extensions are compile‑time sugar — they do not add runtime cost, but they do affect readability. Use them to express intent, not to hide side effects.
Wrapping up
Adding a handful of extension methods turned a verbose request‑building block into a single expressive chain. The pattern scales: any class that suffers from “setter soup” can get a fluent façade without touching the original source. Next time you find yourself writing repetitive mutation code, ask whether an extension could give you a cleaner, self‑documenting API.