From a long constructor to named steps
Think of ordering at a sandwich shop. You don’t shout one long sentence with every possible option in a fixed order. You say: “a baguette… with chicken… no onions… extra cheese… that’s it”. Each choice has a name, you skip what you don’t care about, and at the end (“that’s it”) the sandwich is made, and the staff can check that it makes sense (no “toasted salad”).
A builder works the same way:
- you create a builder,
- you call a named method for each setting you want, in any order, skipping the rest,
- you call
build(), which checks the settings and creates the finished object.
The finished object can be immutable: once built, nothing changes it.
Builder separates the construction of a complex object from its representation: the builder accumulates (mutable) configuration, and build() validates it as a whole and produces an immutable product. In Java it’s the standard answer to telescoping constructors (Effective Java, item 2), and it’s everywhere in the ecosystem (HttpRequest.newBuilder(), OkHttp, Retrofit, protobuf, Lombok @Builder).
In Kotlin and Swift, named arguments with defaults and copy() (or struct copies) cover the readability problem directly, as do options objects and spread copies in TypeScript, so builders remain for specific reasons: Java interop, cross-field validation, incremental construction, DSLs (type-safe builders, result builders) and binary compatibility of public APIs. The Detailed version covers those and the bugs builders introduce: shared mutable state, leaked collections and silently dropped defaults.
Before: Eight parameters, most of them null
final class AppNotification {
init(title: String, body: String, imageURL: URL?, deepLink: URL?,
playsSound: Bool, isSilent: Bool, priority: Int, groupID: String?) { /* … */ }
}
let n = AppNotification(title: "Order shipped", body: "Arriving Tuesday",
imageURL: nil, deepLink: nil, playsSound: true,
isSilent: false, priority: 3, groupID: nil)class AppNotification(
title: String, body: String, imageUrl: String?, deepLink: String?,
playsSound: Boolean, isSilent: Boolean, priority: Int, groupId: String?,
)
val n = AppNotification("Order shipped", "Arriving Tuesday", null, null, true, false, 3, null)public AppNotification(String title, String body, String imageUrl, String deepLink,
boolean playsSound, boolean isSilent, int priority, String groupId) { /* … */ }
AppNotification n = new AppNotification("Order shipped", "Arriving Tuesday",
null, null, true, false, 3, null);class AppNotification {
constructor(title: string, body: string, imageUrl: string | null, deepLink: string | null,
playsSound: boolean, isSilent: boolean, priority: number, groupId: string | null) { /* … */ }
}
const n = new AppNotification("Order shipped", "Arriving Tuesday", null, null, true, false, 3, null);After: Name what you set, skip the rest
// Swift: a struct with default values gets a memberwise initializer with named arguments.
struct AppNotification {
var title: String
var body: String
var imageURL: URL? = nil
var deepLink: URL? = nil
var playsSound = true
var priority: Priority = .normal
var groupID: String? = nil
}
let n = AppNotification(title: "Order shipped", body: "Arriving Tuesday", priority: .high)// Kotlin: named arguments with defaults do the builder's job.
data class AppNotification(
val title: String,
val body: String,
val imageUrl: String? = null,
val deepLink: String? = null,
val playsSound: Boolean = true,
val priority: Priority = Priority.NORMAL,
val groupId: String? = null,
)
val n = AppNotification(title = "Order shipped", body = "Arriving Tuesday", priority = Priority.HIGH)// Java: no named arguments, so a builder gives every setting a name.
public final class AppNotification {
private final String title, body, imageUrl, deepLink, groupId;
private final boolean playsSound;
private final Priority priority;
private AppNotification(Builder b) {
title = b.title; body = b.body; imageUrl = b.imageUrl; deepLink = b.deepLink;
playsSound = b.playsSound; priority = b.priority; groupId = b.groupId;
}
public static Builder builder(String title, String body) { return new Builder(title, body); }
public static final class Builder {
private final String title, body; // required: in the constructor
private String imageUrl, deepLink, groupId; // optional: methods with defaults
private boolean playsSound = true;
private Priority priority = Priority.NORMAL;
private Builder(String title, String body) { this.title = title; this.body = body; }
public Builder imageUrl(String url) { this.imageUrl = url; return this; }
public Builder deepLink(String link) { this.deepLink = link; return this; }
public Builder silent() { this.playsSound = false; return this; }
public Builder priority(Priority p) { this.priority = p; return this; }
public Builder group(String id) { this.groupId = id; return this; }
public AppNotification build() {
if (title.isBlank()) throw new IllegalStateException("title is required");
return new AppNotification(this);
}
}
}
AppNotification n = AppNotification.builder("Order shipped", "Arriving Tuesday")
.priority(Priority.HIGH)
.build();// TypeScript: an options object names every setting; defaults fill in the rest.
interface AppNotification {
readonly title: string;
readonly body: string;
readonly imageUrl?: string;
readonly deepLink?: string;
readonly playsSound: boolean;
readonly priority: Priority;
readonly groupId?: string;
}
type NotificationOptions = Pick<AppNotification, "title" | "body"> & Partial<AppNotification>;
function createNotification(options: NotificationOptions): AppNotification {
if (options.title.trim() === "") throw new Error("title is required");
return { playsSound: true, priority: "normal", ...options };
}
const n = createNotification({ title: "Order shipped", body: "Arriving Tuesday", priority: "high" });Look at the “after” versions:
- Every setting has a name at the call site:
priority: .high,priority = Priority.HIGH,.priority(Priority.HIGH),priority: "high". Swapping two booleans is no longer possible by accident. - You only write what’s different from the defaults. The other five settings keep sensible values.
- Required settings are required.
titleandbodyhave no defaults (Swift, Kotlin), aren’t optional in the options type (TypeScript) or go intobuilder(title, body)(Java), so you can’t forget them. - In Java,
build()is the one place to check the settings make sense. EachBuildermethod returnsthis, so calls chain into one readable expression (a fluent interfaceFluent interfaceAn API whose methods return an object you can keep calling, so a configuration reads like a sentence.Open in glossary →).
The lesson for Swift, Kotlin and TypeScript: named arguments with defaults, or an options object, are a builder built into the language. You’ll still meet builders there, mostly in Android and Java libraries, and in TypeScript query builders.
Builders you already use
| Platform | Builders |
|---|---|
| iOS | URLComponents (set parts, read .url), SwiftUI modifiers (each returns a new view), AttributedString, result builders behind VStack { … } |
| Android | AlertDialog.Builder, NotificationCompat.Builder, OkHttpClient.Builder, Retrofit.Builder, buildList { }, buildString { } |
| Java | StringBuilder, HttpRequest.newBuilder(), Stream.builder(), Lombok @Builder, Spring’s UriComponentsBuilder, WebClient.builder() |
| TypeScript | fetch(url, options) and other options objects, URLSearchParams, query builders (Knex, Kysely, Drizzle), test-data builders, Zod schemas (z.string().min(1).optional()) |
When a builder still earns its place
- Java callers. Kotlin default arguments are invisible to Java without
@JvmOverloads(which generates a constructor per trailing default, not per combination). A library consumed from Java needs a builder. - Incremental construction. When the settings arrive over time (a parser reading a file, a wizard across screens, a request assembled by interceptors), a builder is the accumulating state, and
build()is the moment it becomes valid. - DSLs. Kotlin type-safe builders (lambdas with receivers, scoped with
@DslMarkerso inner blocks can’t call outer builders’ methods) and Swift@resultBuilder(ViewBuilder,RegexComponentBuilder) are builders turned into syntax. - TypeScript. An options object covers almost every case; a fluent builder earns its place in query builders and test fixtures, where settings arrive step by step and the chain reads like the thing it builds.
- Compile-time required steps. A staged (step) builder returns a different interface after each required step (
builder().url(u).method(m).build()wherebuildexists only aftermethod). It’s verbose, but moves “forgot a required field” from runtime to compile time where named arguments can’t (required parameters already do that, so it’s mainly a Java tool).
Public APIs and binary compatibility
For a library, the constructor is a contract. Adding a parameter to a Kotlin data class changes its constructor, copy() and componentN() signatures: source-compatible with defaults, but binary-incompatible for code compiled against the old version, which fails at runtime with NoSuchMethodError. The same applies to Java constructors and @JvmOverloads.
That’s why many Kotlin library authors avoid data classes in public APIs and expose a class with a private constructor plus a builder or a DSL: new options become new builder methods, which is an additive, binary-compatible change. Swift has the same concern for ABI-stable (library evolution) modules, where adding a stored property with a default is resilient but changing initializer signatures is not. TypeScript has no binary contract, but the same thinking applies to source: positional parameters can’t grow in the middle, while an options object takes new optional properties without touching any caller.
Configuration API for an SDK
You’re publishing an analytics SDK used from Swift apps, from Android apps written in both Kotlin and Java, and from web apps in TypeScript (and plain JavaScript). It needs configuration:
- API key (required),
- flush interval (default 30 s, between 5 s and 1 h),
- max batch size (default 100),
- endpoint (default production URL),
- debug logging (default off),
- event filter: a function that can drop events before sending (default: keep all).
Design the configuration API for each platform. It must be pleasant to call, validated, immutable once built, and you must be able to add options in future versions without breaking apps compiled against the old SDK.
›
On Android, think about what Java callers see, and what adding a parameter to a public data class does to its binary signature.
›
Swift has no Java to worry about: named arguments with defaults are idiomatic. But ask what changes in a future SDK version would break apps compiled against this one.
SolutionTry it yourself first
public struct AnalyticsConfiguration: Sendable {
public var apiKey: String
public var flushInterval: Duration = .seconds(30)
public var maxBatchSize = 100
public var endpoint = URL(string: "https://events.example.com")!
public var debugLogging = false
public var eventFilter: @Sendable (Event) -> Bool = { _ in true }
public init(apiKey: String) { // one stable initializer: new options are properties
self.apiKey = apiKey
}
}
// Validated where it's used, once:
public func start(_ configuration: AnalyticsConfiguration) throws {
guard !configuration.apiKey.isEmpty else { throw AnalyticsError.missingAPIKey }
guard (.seconds(5)...(.seconds(3600))).contains(configuration.flushInterval) else {
throw AnalyticsError.invalidFlushInterval
}
// …
}
var config = AnalyticsConfiguration(apiKey: "key")
config.debugLogging = true
try Analytics.start(config)class AnalyticsConfig private constructor(builder: Builder) {
val apiKey: String = builder.apiKey
val flushInterval: Duration = builder.flushInterval
val maxBatchSize: Int = builder.maxBatchSize
val endpoint: String = builder.endpoint
val debugLogging: Boolean = builder.debugLogging
val eventFilter: (Event) -> Boolean = builder.eventFilter
fun newBuilder(): Builder = Builder(apiKey).also {
it.flushInterval = flushInterval; it.maxBatchSize = maxBatchSize
it.endpoint = endpoint; it.debugLogging = debugLogging; it.eventFilter = eventFilter
}
class Builder(internal val apiKey: String) {
var flushInterval: Duration = 30.seconds
var maxBatchSize: Int = 100
var endpoint: String = DEFAULT_ENDPOINT
var debugLogging: Boolean = false
var eventFilter: (Event) -> Boolean = { true }
// Java-friendly fluent setters next to the Kotlin properties:
fun flushInterval(value: java.time.Duration) = apply { flushInterval = value.toKotlinDuration() }
fun maxBatchSize(value: Int) = apply { maxBatchSize = value }
fun debugLogging(value: Boolean) = apply { debugLogging = value }
fun eventFilter(value: java.util.function.Predicate<Event>) = apply { eventFilter = value::test }
fun build(): AnalyticsConfig {
require(apiKey.isNotBlank()) { "apiKey is required" }
require(flushInterval in 5.seconds..1.hours) { "flushInterval must be between 5 s and 1 h" }
require(maxBatchSize in 1..1_000) { "maxBatchSize must be 1 to 1000" }
return AnalyticsConfig(this)
}
}
}
/** The Kotlin entry point: a DSL over the same builder. */
fun analyticsConfig(apiKey: String, configure: AnalyticsConfig.Builder.() -> Unit = {}) =
AnalyticsConfig.Builder(apiKey).apply(configure).build()
// Kotlin: analyticsConfig("key") { debugLogging = true; flushInterval = 10.seconds }
// Java: new AnalyticsConfig.Builder("key").debugLogging(true).build();// What Java callers of the Kotlin SDK write:
AnalyticsConfig config = new AnalyticsConfig.Builder("key")
.flushInterval(Duration.ofSeconds(10))
.eventFilter(event -> !event.name().startsWith("debug_"))
.build();// The web SDK: one options object, defaults and validation in one factory.
export interface AnalyticsOptions {
apiKey: string;
flushIntervalMs?: number; // default 30 000, between 5 000 and 3 600 000
maxBatchSize?: number; // default 100
endpoint?: string;
debugLogging?: boolean;
eventFilter?: (event: AnalyticsEvent) => boolean;
}
export type AnalyticsConfig = Readonly<Required<AnalyticsOptions>>;
export function createConfig(options: AnalyticsOptions): AnalyticsConfig {
const config: AnalyticsConfig = {
apiKey: options.apiKey,
flushIntervalMs: options.flushIntervalMs ?? 30_000,
maxBatchSize: options.maxBatchSize ?? 100,
endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
debugLogging: options.debugLogging ?? false,
eventFilter: options.eventFilter ?? (() => true),
};
if (typeof config.apiKey !== "string" || config.apiKey === "") throw new Error("apiKey is required");
if (!(config.flushIntervalMs >= 5_000 && config.flushIntervalMs <= 3_600_000)) {
throw new Error("flushIntervalMs must be between 5 s and 1 h");
}
if (!Number.isInteger(config.maxBatchSize) || config.maxBatchSize < 1 || config.maxBatchSize > 1_000) {
throw new Error("maxBatchSize must be 1 to 1000");
}
return Object.freeze(config);
}
// analytics.start(createConfig({ apiKey: "key", debugLogging: true }));Decisions to defend:
- Android: a class with a private constructor and a builder, not a public
data class. New options in v2 are new builder properties and methods, an additive change that doesn’t break binaries compiled against v1. A data class would change its constructor,copyandcomponentNsignatures. Java callers get fluent setters (java.time.Duration,Predicate), Kotlin callers get a DSL; both go through onebuild()with all the validation. - Swift: a struct with one minimal initializer and
varproperties with defaults, configured by mutation before it’s passed in. Adding a property with a default in v2 doesn’t change the initializer, so existing code keeps compiling (and, with library evolution enabled, keeps linking). A memberwise initializer with every option as a parameter would grow with each release. - Web: one options object and a factory. New options in v2 are new optional properties, which no caller has to change. Validate at runtime too (
??for defaults, so0andfalsesurvive): plain JavaScript callers and JSON-loaded configuration bypass the types. - Validation at a single entry point (
build(),start(),createConfig()), with errors that name the option and the allowed range. - Immutable once in use: the SDK copies the struct (Swift) or receives an immutable object (Kotlin); changing options later means
newBuilder()and restarting, never mutating a live configuration from another thread. - Closures are part of the config, so they must be thread-safe (
@Sendable) and must not capture UI objects; document which thread they’re called on.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Many optional settings | struct with defaults, memberwise init | named + default arguments | builder | options object with optional properties and defaults |
| Modified copy | var c = v; c.x = … / modifier methods | copy(x = …) | toBuilder() / newBuilder() | spread: { ...options, retries: 0 } |
| DSL | @resultBuilder | lambda with receiver + @DslMarker | — | fluent builder returning this (query builders, test fixtures) |
| Validation | throwing init | init { require(...) } / build() | build() | the constructor or a factory function |
| Interop concern | — | builder or @JvmOverloads for Java | native | — (but runtime input needs validation: types vanish) |
Use a builder when: Java callers, incremental construction, cross-field validation, DSLs, or a public API that must evolve without binary breaks.
TypeScript
Adding an optional property to an options type is non-breaking for callers, which is why JavaScript libraries use options objects (fetch(url, { method, headers })) where Java uses builders.
struct RequestOptions {
var timeout: Duration = .seconds(30)
var retries = 2
var headers: [String: String] = [:]
}
let options = RequestOptions(retries: 0) // name only what differsdata class RequestOptions(
val timeout: Duration = 30.seconds,
val retries: Int = 2,
val headers: Map<String, String> = emptyMap(),
)
val options = RequestOptions(retries = 0) // name only what differs
val noTimeout = options.copy(timeout = Duration.INFINITE)HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
.GET()
.build();
HttpRequest.Builder next = HttpRequest.newBuilder(request, (name, value) -> true); // from an existing requestinterface RequestOptions {
timeoutMs?: number;
retries?: number;
headers?: Record<string, string>;
}
const defaults: Required<RequestOptions> = { timeoutMs: 30_000, retries: 2, headers: {} };
const options = { ...defaults, retries: 0 }; // name only what differs
const noTimeout = { ...options, timeoutMs: Infinity }; // a modified copyCheck this part
Why is
AppNotification(title: …, body: …, priority: .high)safer than eight positional arguments?Why isAppNotification(title = …, body = …, priority = Priority.HIGH)safer than eight positional arguments?Why isAppNotification.builder(title, body).priority(Priority.HIGH).build()safer than eight positional arguments?Why iscreateNotification({ title, body, priority: "high" })safer than eight positional arguments?Why do you rarely write builders in Swift?Why do you rarely write builders for Kotlin-only code?Why does Java need builders more than Swift or Kotlin?Why do you rarely need a builder class in TypeScript?
A public SDK struct exposes a memberwise-style initializer with every option as a parameter. What happens in v2 when you add an option?A public SDK exposes
data class Config(...). In v2 you add a property with a default. What happens to apps compiled against v1?A public SDK exposes a constructor with every option. In v2 you add a parameter. What happens to apps compiled against v1?A public SDK function takes eight positional parameters. In v2 you need a ninth option in the middle. What happens to callers?