How is Kotest's table DSL typed — what do `headers(...)`, `row(...)` and `table(...)` produce, how does the `forAll` lambda receive the columns, and what are the limits of that design?
answer
- one generated type per arity: rowN / headersN / tableN
- forAll spreads columns into N typed lambda params (not destructuring)
- arity + header count checked at compile time
- mixed types in a column widen to a common supertype
- positional only — headers label failures, never look up columns
basics
~20 sEach arity has its own generated type — row(1, "a") is a two-column row, and table(headers, rows...) fixes both the column count and each column's type. forAll then hands the lambda one typed parameter per column. Limits: fixed arity, one type per column across all rows, positional access only.
solid answer
~60 sKotest generates one type per arity: `row(...)` with N values produces an N-column row type parameterised by each column's type, `headers(...)` with N labels produces the matching N-header type, and `table(...)` only accepts a headers value and rows of the same arity. The `forAll`/`forNone` overload for that arity then invokes your lambda with N separate, correctly typed parameters — `forAll(t) { a: Int, b: String, expected: Boolean -> ... }`. So the safety is real and compile-time: a header count that doesn't match the rows, a row with the wrong number of values, or a lambda with the wrong arity are all compile errors, and each column keeps its own static type without casts. The limits follow from the same design. Arity is fixed and generated only up to a bound, so very wide tables don't fit. All rows share one type per column, so a genuinely heterogeneous column widens to a common supertype. And access is positional — there is no by-name column access at runtime; `headers` only labels failure output.
code
kotlin · 12 linesval cases = table(
headers("input", "locale", "expected"),
row("1.5", Locale.US, 1.5),
row("1,5", Locale.GERMANY, 1.5),
)
test("locale-aware parsing") {
forAll(cases) { input, locale, expected ->
// input: String, locale: Locale, expected: Double — no casts
parseNumber(input, locale) shouldBe expected
}
}go deeper
Know that row(...) values arrive as separate typed parameters in the forAll lambda and that the compiler checks the counts match.
Explain the arity-generated types, what is checked at compile time, and that headers only label failure output.
Add the design limits — bounded arity, one type per column, positional-only access — and the habits that avoid them (domain objects in rows, consistent input/expected ordering).
Frame it as an API-design trade-off: generated arity types buy compile-time safety at the cost of width and named access, so set conventions for row shape rather than growing tables sideways.
## What the DSL actually builds Kotest's table DSL (`io.kotest.data`) is a set of **generated, arity-specific types**. `row(1, "a")` produces a two-column row type parameterised as `<Int, String>`; `row(1, "a", true)` produces a three-column one parameterised as `<Int, String, Boolean>`. `headers("n", "s")` likewise produces a two-header value. `table(headers, row, row, ...)` has an overload per arity that only accepts headers and rows of matching width, and the resulting table carries the column types in its own type parameters. `forAll` and `forNone` then have one overload per arity, each accepting a function of exactly that many parameters: ```kotlin val t = table( headers("input", "locale", "expected"), row("1.5", Locale.US, 1.5), row("1,5", Locale.GERMANY, 1.5), ) forAll(t) { input: String, locale: Locale, expected: Double -> parse(input, locale) shouldBe expected } ``` The lambda receives three separate parameters, each statically typed. Note that this is **not** Kotlin destructuring — there is no `component1()`/`component2()` on a tuple here; the arity-specific `forAll` overload spreads the row's columns into the function's parameter list. The practical difference is that you cannot use destructuring-only features (like `val (a, _) = row`) on it, and the parameter count is fixed by the overload rather than by a `data class`. ## What this buys you **Compile-time arity checking.** Three headers and a two-value row do not typecheck. A lambda with the wrong number of parameters does not typecheck. This is the main advantage over hand-rolling a `List<Array<Any>>`, which pushes both errors to runtime. **Per-column static types, no casts.** Column 2 is a `Locale` everywhere, so the body gets a `Locale` — no `as` casts, no `Any` handling, and IDE completion works inside the lambda. **Readable failures.** The labels in `headers(...)` are paired with each failing row's values in the failure message. They serve no other purpose — they are not the lambda's parameter names and are not usable to look a column up by name. ## The limits **1. Fixed, bounded arity.** There is one generated type per column count, up to a bound. Wide tables — a dozen-plus columns — either do not fit or become unreadable long before they do. The idiomatic escape is to collapse related columns into one domain object: a single `row(request, expectedResponse)` beats eight loose primitives, and it gives the failure output something with a meaningful `toString()`. **2. One type per column, across all rows.** The table's type is fixed by its generic parameters, so every row must supply the same type in a given column. Mix an `Int` and a `String` in column 1 and Kotlin infers a common supertype (`Any`, or `Comparable<*>`-ish), and your lambda parameter degrades to that supertype, forcing casts inside the body. If you genuinely need heterogeneous cases, that is a sign you have two tables, or that the column should be a sealed type. **3. Positional access only.** Inside the lambda, columns are identified by position and by whatever names you gave the parameters. Nothing at runtime lets you say `row["expected"]`. It follows that a mistake where two same-typed columns are swapped (`row(expected, actual)` instead of `row(actual, expected)`) type-checks perfectly and produces confusing failures. Ordering conventions — inputs first, expected value last — are worth writing down. **4. No per-row reporting.** The types say nothing about the report: the whole table still runs inside one test node, so per-row identity exists only in the failure message. ## Choosing row shapes well A few habits that make typed tables pleasant: - Put all inputs first and the expected value last, consistently. - Prefer one domain object over four primitives when the primitives always travel together; it shortens the arity and improves failure output. - Give any custom type used in a row a readable `toString()` — the value is printed when the row fails. - Extract the table to a `val` outside the test when several tests share it; the type is inferred and reusable. ## How to say it in an interview "Kotest generates a type per arity: `row`, `headers` and `table` are all arity-parameterised, so header count, row width and lambda arity are checked at compile time, and each column keeps its own static type — `forAll` spreads the columns into separate lambda parameters rather than destructuring a tuple. The limits are the flip side: bounded arity, one type per column across all rows so mixed types widen to a supertype, and positional-only access, since headers exist only to label failure messages."
- What happens if one column holds an `Int` in one row and a `String` in another?Kotlin infers a common supertype for that column's type parameter, typically `Any` or a shared interface, and the table still compiles. The cost lands in the lambda: that parameter arrives as the widened type, so the body needs casts or a `when` to recover the real type. It usually means the table is really two tables, or that the column should be modelled as a sealed type whose branches the body handles explicitly.
- If two columns have the same type and you swap them in one row, will anything catch it?No — swapping two same-typed columns type-checks perfectly, because arity and types still match. The only signal is a confusing failure message where the values appear under the wrong header labels. The mitigations are conventions (inputs first, expected value last), and collapsing related primitives into distinct domain types so the compiler can tell them apart.
saying these in an interview costs you the question
- "The lambda destructures a tuple" — the arity-specific overload spreads columns into separate parameters; there is no component1/component2 tuple to destructure.
- "You can look a column up by its header name" — headers only label values in failure output; access is positional.
- "Rows can have different widths in the same table" — arity is fixed by the table's type and mismatches are compile errors.
- "Header count mismatches fail at runtime" — they fail to compile, because headers and rows are arity-typed.
- "Mixing types in a column is fine, Kotest boxes it" — it compiles by widening to a common supertype, which then forces casts in the body.