Skip to content
Commitwise
Menu
Design Patterns

Builder

Create objects with many optional settings step by step and validate them in one place, and learn when named arguments or options objects make a builder unnecessary, with examples in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

What does this line do?

new Notification("Order shipped", "Arriving Tuesday", null, null, true, false, 3, null);

Nobody knows without opening the constructor. Is true “sound on” or “can dismiss”? What’s 3? And when someone swaps the two booleans, it still compiles. Constructors with many parameters, most of them optional, are one of the most common sources of quiet bugs.

The Builder pattern fixes this in Java. Swift and Kotlin (named arguments) and TypeScript (options objects) have language features that fix most of it without a builder, and knowing which one to reach for is part of the skill.

New to some of these? Open what you need, or skip ahead.

Basics:What is a design pattern?

A design pattern is a named, reusable solution to a problem that keeps coming up in software design, like “swap one algorithm for another” or “notify many objects of a change”. It’s not a library you install; it’s a way of arranging classes and interfaces that experienced developers recognize.

Knowing the names helps in two ways: you recognize the pattern in code you read (Android, iOS and Spring use many of them), and you can discuss designs quickly in reviews and interviews (“let’s use a Strategy here”).

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. In TypeScript, plain objects described by an interface or type often play that role.
Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, Swift’s let and structs, and TypeScript’s readonly (checked at compile time) and Object.freeze (enforced at runtime) are the usual tools.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Part Not started

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:

  1. you create a builder,
  2. you call a named method for each setting you want, in any order, skipping the rest,
  3. 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. title and body have no defaults (Swift, Kotlin), aren’t optional in the options type (TypeScript) or go into builder(title, body) (Java), so you can’t forget them.
  • In Java, build() is the one place to check the settings make sense. Each Builder method returns this, 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

PlatformBuilders
iOSURLComponents (set parts, read .url), SwiftUI modifiers (each returns a new view), AttributedString, result builders behind VStack { … }
AndroidAlertDialog.Builder, NotificationCompat.Builder, OkHttpClient.Builder, Retrofit.Builder, buildList { }, buildString { }
JavaStringBuilder, HttpRequest.newBuilder(), Stream.builder(), Lombok @Builder, Spring’s UriComponentsBuilder, WebClient.builder()
TypeScriptfetch(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 @DslMarker so 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() where build exists only after method). 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.

Exercisemedium

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, copy and componentN signatures. Java callers get fluent setters (java.time.Duration, Predicate), Kotlin callers get a DSL; both go through one build() with all the validation.
  • Swift: a struct with one minimal initializer and var properties 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, so 0 and false survive): 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.
SwiftKotlinJavaTypeScript
Many optional settingsstruct with defaults, memberwise initnamed + default argumentsbuilderoptions object with optional properties and defaults
Modified copyvar c = v; c.x = … / modifier methodscopy(x = …)toBuilder() / newBuilder()spread: { ...options, retries: 0 }
DSL@resultBuilderlambda with receiver + @DslMarker—fluent builder returning this (query builders, test fixtures)
Validationthrowing initinit { require(...) } / build()build()the constructor or a factory function
Interop concern—builder or @JvmOverloads for Javanative— (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 differs
data 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 request
interface 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 copy

Check this part

  1. Why is AppNotification(title: …, body: …, priority: .high) safer than eight positional arguments?Why is AppNotification(title = …, body = …, priority = Priority.HIGH) safer than eight positional arguments?Why is AppNotification.builder(title, body).priority(Priority.HIGH).build() safer than eight positional arguments?Why is createNotification({ title, body, priority: "high" }) safer than eight positional arguments?

  2. 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?

  3. 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?

A builder gives every setting a name, lets you skip the optional ones and validates the whole object in build(). Named arguments (Swift, Kotlin) and options objects (TypeScript) do the same job without a builder.

Part Not started

Fluent methods return the same object

Builder methods end with return this, which is what makes chaining work. It also means a chain doesn’t create new builders:

Predict the output

What gets printed?

var first = "a"
var second = first
second += "b"
second += "c"
print(first)
val sb = StringBuilder("a")
val other = sb.append("b")
other.append("c")
println(sb)
StringBuilder sb = new StringBuilder("a");
StringBuilder other = sb.append("b");
other.append("c");
System.out.println(sb);
const parts = ["a"];
const other = parts;
other.push("b");
other.push("c");
console.log(parts.join(""));

Practice: one object, each language’s idiom

Let’s build a pizza order in each language’s idiom: a Java builder (TypeScript uses the same fluent shape), a Kotlin DSL (the style of buildList { } and Gradle files), and Swift modifier methods (the style of SwiftUI). Step by step:

  1. Decide what’s required (size) and what’s optional (toppings, extra cheese, delivery note).
  2. Choose sensible defaults for the optional parts.
  3. Collect the settings, then build an immutable Pizza.
  4. Validate the combination in one place: at most 5 toppings.
struct Pizza {
    enum Size { case small, medium, large }

    let size: Size
    private(set) var toppings: [String] = []
    private(set) var extraCheese = false

    init(_ size: Size) { self.size = size }

    // Each method returns a modified copy, like SwiftUI modifiers.
    func topping(_ name: String) -> Pizza {
        var copy = self
        copy.toppings.append(name)
        return copy
    }

    func withExtraCheese() -> Pizza {
        var copy = self
        copy.extraCheese = true
        return copy
    }

    func validated() throws -> Pizza {
        guard toppings.count <= 5 else { throw PizzaError.tooManyToppings }
        return self
    }
}

let pizza = try Pizza(.large)
    .topping("mushrooms")
    .topping("olives")
    .withExtraCheese()
    .validated()
class Pizza private constructor(
    val size: Size,
    val toppings: List<String>,
    val extraCheese: Boolean,
) {
    enum class Size { SMALL, MEDIUM, LARGE }

    class Builder(private val size: Size) {
        private val toppings = mutableListOf<String>()
        var extraCheese = false

        fun topping(name: String) { toppings += name }

        fun build(): Pizza {
            require(toppings.size <= 5) { "At most 5 toppings" }
            return Pizza(size, toppings.toList(), extraCheese)   // a read-only copy
        }
    }
}

// A function that takes a lambda with the builder as its receiver: a tiny DSL.
fun pizza(size: Pizza.Size, configure: Pizza.Builder.() -> Unit): Pizza =
    Pizza.Builder(size).apply(configure).build()

val order = pizza(Pizza.Size.LARGE) {
    topping("mushrooms")
    topping("olives")
    extraCheese = true
}
public final class Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }

    private final Size size;
    private final List<String> toppings;
    private final boolean extraCheese;

    private Pizza(Builder b) {
        size = b.size;
        toppings = List.copyOf(b.toppings);     // the product doesn't share the builder's list
        extraCheese = b.extraCheese;
    }

    public static Builder builder(Size size) { return new Builder(size); }

    public static final class Builder {
        private final Size size;
        private final List<String> toppings = new ArrayList<>();
        private boolean extraCheese;

        private Builder(Size size) { this.size = Objects.requireNonNull(size); }

        public Builder topping(String name) { toppings.add(name); return this; }
        public Builder extraCheese() { extraCheese = true; return this; }

        public Pizza build() {
            if (toppings.size() > 5) throw new IllegalStateException("At most 5 toppings");
            return new Pizza(this);
        }
    }
}

Pizza pizza = Pizza.builder(Pizza.Size.LARGE)
    .topping("mushrooms")
    .topping("olives")
    .extraCheese()
    .build();
type Size = "small" | "medium" | "large";

interface Pizza {
  readonly size: Size;
  readonly toppings: readonly string[];
  readonly extraCheese: boolean;
}

class PizzaBuilder {
  private readonly toppings: string[] = [];
  private extraCheese = false;

  constructor(private readonly size: Size) {}

  topping(name: string): this {
    this.toppings.push(name);
    return this;
  }

  withExtraCheese(): this {
    this.extraCheese = true;
    return this;
  }

  build(): Pizza {
    if (this.toppings.length > 5) throw new Error("At most 5 toppings");
    return Object.freeze({
      size: this.size,
      toppings: Object.freeze([...this.toppings]),   // the product doesn't share the builder's array
      extraCheese: this.extraCheese,
    });
  }
}

const pizza = new PizzaBuilder("large")
  .topping("mushrooms")
  .topping("olives")
  .withExtraCheese()
  .build();

All three end up with the same thing: an immutable pizza, required size up front, optional parts by name, and one validation step. The style follows the language: Swift prefers values that return modified copies, Kotlin prefers DSLs with lambdas, Java prefers chained builders. TypeScript would usually take an options object (createPizza({ size: "large", toppings: ["olives"] })) and keep a fluent builder for settings that arrive step by step.

Validation only helps if build() does it. What’s missing here?

Spot the bugA booking that ends before it starts

Tap the line with the bug.

struct BookingBuilder {
    var start: Date?
    var end: Date?
    var guests = 1

    func build() -> Booking {
        Booking(start: start ?? .now, end: end ?? .now, guests: guests)
    }
}
class BookingBuilder {
    var start: LocalDate? = null
    var end: LocalDate? = null
    var guests = 1

    fun build(): Booking =
        Booking(start ?: LocalDate.now(), end ?: LocalDate.now(), guests)
}
class BookingBuilder {
    private LocalDate start, end;
    private int guests = 1;

    BookingBuilder start(LocalDate d) { start = d; return this; }
    BookingBuilder end(LocalDate d) { end = d; return this; }
    BookingBuilder guests(int n) { guests = n; return this; }

    Booking build() {
        return new Booking(start, end, guests);
    }
}
class BookingBuilder {
  start?: Date;
  end?: Date;
  guests = 1;

  build(): Booking {
    return { start: this.start ?? new Date(), end: this.end ?? new Date(), guests: this.guests };
  }
}

Exerciseeasy

An email message

Create an EmailMessage type for an app’s “share by email” feature. It has:

  • to: at least one recipient (required),
  • cc: any number of recipients (optional),
  • subject (required, not blank) and body (optional, default empty),
  • attachments: optional, at most 10.

The finished message must be immutable, and invalid messages must be impossible to create. Use the idiom that fits your language: named arguments with defaults (Swift, Kotlin), an options object (TypeScript) or a builder (Java).

›

In Swift, Kotlin and TypeScript, put validation in the initializer (init that throws, init { require(...) }, or a constructor that throws), so every way of creating the object goes through it.

›

For lists, store an immutable copy of what the caller passed in, so changing their list later doesn’t change your message.

SolutionTry it yourself first
struct EmailMessage {
    let to: [String]
    let cc: [String]
    let subject: String
    let body: String
    let attachments: [URL]

    init(to: [String], cc: [String] = [], subject: String, body: String = "", attachments: [URL] = []) throws {
        guard !to.isEmpty else { throw EmailError.noRecipients }
        guard !subject.trimmingCharacters(in: .whitespaces).isEmpty else { throw EmailError.blankSubject }
        guard attachments.count <= 10 else { throw EmailError.tooManyAttachments }
        self.to = to; self.cc = cc; self.subject = subject; self.body = body; self.attachments = attachments
    }
}

let mail = try EmailMessage(to: ["ana@example.com"], subject: "Your receipt")
class EmailMessage(
    to: List<String>,
    cc: List<String> = emptyList(),
    val subject: String,
    val body: String = "",
    attachments: List<File> = emptyList(),
) {
    val to: List<String> = to.toList()                 // copies: the caller's lists can't change ours
    val cc: List<String> = cc.toList()
    val attachments: List<File> = attachments.toList()

    init {
        require(this.to.isNotEmpty()) { "At least one recipient" }
        require(subject.isNotBlank()) { "Subject is required" }
        require(this.attachments.size <= 10) { "At most 10 attachments" }
    }
}

val mail = EmailMessage(to = listOf("ana@example.com"), subject = "Your receipt")
public final class EmailMessage {
    private final List<String> to, cc;
    private final String subject, body;
    private final List<Path> attachments;

    private EmailMessage(Builder b) {
        to = List.copyOf(b.to); cc = List.copyOf(b.cc);
        subject = b.subject; body = b.body; attachments = List.copyOf(b.attachments);
    }

    public static Builder builder(String subject) { return new Builder(subject); }

    public static final class Builder {
        private final String subject;
        private final List<String> to = new ArrayList<>(), cc = new ArrayList<>();
        private final List<Path> attachments = new ArrayList<>();
        private String body = "";

        private Builder(String subject) { this.subject = subject; }

        public Builder to(String address) { to.add(address); return this; }
        public Builder cc(String address) { cc.add(address); return this; }
        public Builder body(String text) { body = text; return this; }
        public Builder attach(Path file) { attachments.add(file); return this; }

        public EmailMessage build() {
            if (to.isEmpty()) throw new IllegalStateException("At least one recipient");
            if (subject == null || subject.isBlank()) throw new IllegalStateException("Subject is required");
            if (attachments.size() > 10) throw new IllegalStateException("At most 10 attachments");
            return new EmailMessage(this);
        }
    }
}

EmailMessage mail = EmailMessage.builder("Your receipt").to("ana@example.com").build();
interface EmailOptions {
  to: readonly string[];
  cc?: readonly string[];
  subject: string;
  body?: string;
  attachments?: readonly File[];
}

class EmailMessage {
  readonly to: readonly string[];
  readonly cc: readonly string[];
  readonly subject: string;
  readonly body: string;
  readonly attachments: readonly File[];

  constructor({ to, cc = [], subject, body = "", attachments = [] }: EmailOptions) {
    if (to.length === 0) throw new Error("At least one recipient");
    if (subject.trim() === "") throw new Error("Subject is required");
    if (attachments.length > 10) throw new Error("At most 10 attachments");
    this.to = [...to];                                   // copies: the caller's arrays can't change ours
    this.cc = [...cc];
    this.subject = subject;
    this.body = body;
    this.attachments = [...attachments];
  }
}

const mail = new EmailMessage({ to: ["ana@example.com"], subject: "Your receipt" });

Same rules, two shapes. With named arguments (or an options object), the “builder” is the call itself, and validation lives in the initializer. In Java, the builder collects and build() validates. Either way, there’s exactly one door into an EmailMessage, and it checks everyone who comes through.

Generated builders and default values

Predict the output

What is config.retries?

struct ClientConfig {
    var retries = 3
    var timeout: Double
}

let config = ClientConfig(timeout: 10)
print(config.retries)
data class ClientConfig(
    val retries: Int = 3,
    val timeout: Double,
)

val config = ClientConfig(timeout = 10.0)
println(config.retries)
@Builder
@Getter
class ClientConfig {
    private int retries = 3;
    private double timeout;
}

ClientConfig config = ClientConfig.builder().timeout(10).build();
System.out.println(config.getRetries());
function createConfig(options: { retries?: number; timeout: number }) {
  return { retries: options.retries || 3, timeout: options.timeout };
}

const config = createConfig({ retries: 0, timeout: 10 });
console.log(config.retries);
  • return this means a chain mutates one builder; two variables pointing at it see every change.
  • Validate cross-field rules in build() (or the initializer): it’s the only place that sees all settings together.

Java

Lombok @Builder ignores field initializers (private int retries = 3 builds as 0) unless the field is @Builder.Default.

Kotlin, Swift

Defaults in the constructor or memberwise initializer are applied when omitted; the Lombok trap is a Java-only problem.

TypeScript

Defaults applied with ?? keep 0 and ""; with || they silently replace them (retries: 0 becomes 3).

Check this part

  1. Why does a builder method end with return this?

  2. Swift modifier methods like .topping("olives") return a modified copy. What does that mean for the original value?

  3. Where should the code check that a booking's end date is after its start date?

  4. struct ClientConfig { var retries = 3; var timeout: Double }. What is ClientConfig(timeout: 10).retries?data class ClientConfig(val retries: Int = 3, val timeout: Double). What is ClientConfig(timeout = 10.0).retries?Lombok @Builder on a class with private int retries = 3. What does builder().timeout(10).build().getRetries() return?const retries = options.retries || 3, called with { retries: 0 }. What is retries?

Fluent methods return the same builder, so a chain configures one object. build() is the one place that sees every setting together, so cross-field rules belong there.

Part Not started

The builder that remembers too much

Builders are mutable. Reuse one, and the next object inherits whatever the previous one set:

Spot the bugRequests with everyone's ids

Tap the line with the bug.

var components = URLComponents(string: "https://api.example.com/products")!
components.queryItems = [URLQueryItem(name: "currency", value: "EUR")]

for id in productIDs {
    components.queryItems?.append(URLQueryItem(name: "id", value: id))
    requests.append(URLRequest(url: components.url!))
}
val builder = Request.Builder()
    .url("https://api.example.com/products")
    .header("Accept", "application/json")

for (id in productIds) {
    val request = builder.addHeader("X-Product-Id", id).build()
    client.newCall(request).enqueue(callback)
}
HttpRequest.Builder base = HttpRequest.newBuilder(URI.create("https://api.example.com/products"))
    .header("Accept", "application/json");

for (String id : productIds) {
    HttpRequest request = base.header("X-Product-Id", id).build();
    client.sendAsync(request, BodyHandlers.ofString());
}
const params = new URLSearchParams({ currency: "EUR" });

for (const id of productIds) {
  params.append("id", id);
  requests.push(new Request("https://api.example.com/products?" + params));
}

Builders and immutability

The builder is mutable and single-use-per-thread; the product should be deeply immutable:

  • Copy collections in build() (List.copyOf, toList(), [...array]), or the product shares the builder’s list (puzzle below).
  • Don’t share builders across threads or store them in singletons. Spring Boot’s auto-configured WebClient.Builder and RestClient.Builder are prototype-scoped for exactly this reason: inject one, configure it, build, and don’t keep mutating it.
  • Modify via toBuilder() (or newBuilder() in OkHttp, copy() in Kotlin, a spread in TypeScript): start a new builder from an existing product, so products are never mutated in place.

The product that changes after build()

Spot the bugAn immutable request that isn't

Tap the line with the bug.

class Request private constructor(val url: String, val headers: List<String>) {

    class Builder(private val url: String) {
        private val headers = mutableListOf<String>()
        fun header(h: String) = apply { headers += h }
        fun build() = Request(url, headers)
    }
}

val builder = Request.Builder("https://api.example.com/me").header("Accept: application/json")
val first = builder.build()
val second = builder.header("Authorization: Bearer token").build()
println(first.headers.size)
final class Request {
    final String url;
    final List<String> headers;
    private Request(String url, List<String> headers) { this.url = url; this.headers = headers; }

    static final class Builder {
        private final String url;
        private final List<String> headers = new ArrayList<>();
        Builder(String url) { this.url = url; }
        Builder header(String h) { headers.add(h); return this; }
        Request build() { return new Request(url, headers); }
    }
}
class RequestBuilder {
  private readonly headers: string[] = [];
  constructor(private readonly url: string) {}
  header(h: string): this { this.headers.push(h); return this; }
  build(): { readonly url: string; readonly headers: readonly string[] } {
    return { url: this.url, headers: this.headers };
  }
}

Deep dives

Deep diveTelescoping constructors, JavaBeans and BuilderInterview favorite

Effective Java (item 2) compares three ways to create objects with many optional parameters. Telescoping constructors (one per combination) are unreadable at the call site. The JavaBeans pattern (an empty constructor plus setters) is readable, but the object is inconsistent between the setter calls and can never be immutable.

The Builder combines the readability of named setters with a single, validated construction step and an immutable result. That comparison is the classic interview answer; the modern addendum is that named and default arguments (Kotlin, Swift) and options objects (TypeScript) solve the same problem in the language.

  • A reused builder accumulates state: start each product from a fresh builder or a copy (HttpRequest.Builder.copy(), OkHttp newBuilder(), a struct copy, an object spread).
  • Copy collections in build(): a read-only type over the builder’s own list is not immutable. Never share builders across threads.

Check this part

  1. You reuse one builder in a loop, adding a header (or query item) per iteration. What does the third request contain?

  2. Why does var c = components inside the loop fix the bug?Why does builder.build().newBuilder() inside the loop fix the bug?Why does base.copy() inside the loop fix the bug?Why does const p = new URLSearchParams(params) inside the loop fix the bug?

  3. A builder passes its headers array to the product. Afterwards it appends another header. Does the product change?build() = Request(url, headers) where headers is the builder's mutableListOf(). After a later header(…) call, how many headers does the first product have?build() returns new Request(url, headers) with the builder's ArrayList. After a later header(…) call, how many headers does the first product have?build() returns { url, headers: this.headers } typed as readonly string[]. After a later header(…) call, how many headers does the first product have?

Builders are mutable: reusing one makes every product inherit the previous settings, and a build() that hands over its own list makes the product change afterwards. Copy for each object and copy collections in build().

Key takeaways

  • A builder lets you set only the options you need, by name, and creates the finished object with build().
  • In Swift and Kotlin, default and named arguments usually replace a builder (in TypeScript, an options object); in Java, builders are the standard tool.
  • Put required values in the constructor (or builder(...)), and check the combination of settings in build() or the initializer.
  • Builders are mutable: don’t reuse one for several objects unless you start from a copy.
  • Builders earn their place for Java interop, incremental construction, DSLs, cross-field validation and evolvable public APIs.
  • Keep the product deeply immutable: copy collections in build(), modify through toBuilder()/copy(), never share builders across threads.
  • Public constructors (and Kotlin data classes) are binary contracts; builders and property-based configuration evolve additively.
  • Watch tool-generated builders: Lombok @Builder silently drops field initializers without @Builder.Default.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.