skip to content

What is a type-safe builder in Kotlin, and what does a builder function like `fun table(init: Table.() -> Unit): Table` actually do?

level: juniorimportance: must knowfreq 55%

answer

  1. Lambda with receiver: Table.() -> Unit
  2. Body is Table().apply(init)
  3. Receiver type fixed at compile time
  4. Compiler checks every nested call
  5. Beats string templating (runtime errors)

basics

~20 s

A function that creates an object, lets you configure it inside a block, and returns it. The compiler checks your configuration code, so typos and wrong calls fail to compile instead of breaking at runtime.

solid answer

~40 s

A type-safe builder is a function that takes a lambda with receiver (`init: Table.() -> Unit`), constructs the target object, runs the lambda against it, and returns the configured object. The canonical body is `Table().apply(init)`: `apply` invokes `init` with the new `Table` as `this`, so inside the block you call the `Table`'s own members directly (e.g. `row { ... }`) without naming the variable. Because the receiver type is fixed at compile time, every nested call is type-checked by the compiler — unlike building markup with string concatenation/templating, where mistakes only surface at runtime. The result reads like a declarative DSL while staying ordinary, statically-typed Kotlin.

code

kotlin · 6 lines
kotlin
fun table(init: Table.() -> Unit): Table = Table().apply(init)

val t = table {
    row { }   // resolves to Table.row, compiler-checked
    row { }
}

go deeper

for a junior

Can describe that the function creates, configures via a block, and returns the object, and that the compiler checks the block.

for a middle

Explains the receiver-lambda + apply mechanism precisely and why it returns the receiver.

for a senior

Contrasts with string templating, notes IDE/refactor benefits, and reaches for this idiom when modeling structured data.

for a principal

Frames type-safe builders as a way to push correctness to compile time and discusses when a DSL is worth the API-design cost versus a plain constructor.

## What is a type-safe builder? A **type-safe builder** is a Kotlin idiom for constructing structured objects (HTML, configuration, UI trees, test data) using nested blocks that *look* like a domain-specific language but are plain, compiler-checked Kotlin code. ## The two ingredients 1. **Lambda with receiver** — a function type written `Receiver.() -> ReturnType`. Inside such a lambda, `this` is the receiver, so you can call the receiver's members without qualification. `Table.() -> Unit` means "a block of code that runs *on* a `Table`". 2. **`apply`** — the standard-library scope function `inline fun <T> T.apply(block: T.() -> Unit): T`. It runs `block` with the object as `this` and returns **the same object**. ## The canonical builder function ```kotlin class Table { val rows = mutableListOf<Row>() fun row(init: Row.() -> Unit) { rows.add(Row().apply(init)) } } class Row { /* ... */ } // The builder entry point: fun table(init: Table.() -> Unit): Table = Table().apply(init) // Usage: val t = table { row { /* configure */ } row { /* configure */ } } ``` - `table { ... }` creates a fresh `Table`, runs the `{ ... }` block with that `Table` as receiver, and returns it. - Inside the block, `row { ... }` resolves to `Table.row`, because the receiver is a `Table`. ## Why "type-safe"? The receiver type is known **at compile time**. So: - Calling a method that doesn't exist on `Table` is a **compile error**. - Passing the wrong argument type is a **compile error**. - IDE autocomplete and refactoring work inside the block. Contrast with string templating (`"<table>$content</table>"`): a malformed tag or wrong value is only discovered when the string is parsed/rendered at runtime. ## Key terms - **Receiver**: the object a lambda or extension runs against; accessible as `this`. - **DSL (domain-specific language)**: an API shaped to read like the problem domain. - **`Unit`**: the return type of the `init` block — we run it for its side effects (mutating the object), not for a returned value.

  • Why use `apply` instead of just `init(Table())`?
    `apply` runs the block and returns the receiver, so the whole thing is one expression. `init(Table())` would return `Unit`, not the configured `Table`.
  • What does `Table.() -> Unit` give you that `(Table) -> Unit` does not?
    With the receiver form you call members directly (`row { }`), with `this` implicit. The plain form would force `it.row { }`, which reads less like a DSL.

Like a fill-in-the-blanks form printed for a specific object: the form only lets you fill fields that exist, so mistakes are caught before you submit.

saying these in an interview costs you the question

  • Thinking the builder is checked only at runtime like a template string
  • Saying `apply` returns `Unit` or a new object instead of the same receiver
  • Confusing `Table.() -> Unit` with `(Table) -> Unit`
  • Believing it requires reflection or annotations to work

context