skip to content

kotest-property offers a reflective form of Arb.bind that builds a generator for a class without you naming any field generators. How does it work, and why would you not use it for your core domain types?

level: seniorimportance: nice to knowfreq 20%

answer

  1. Arb.bind<T>() — reified, no arguments
  2. resolves primary-constructor params from built-in defaults
  3. no constraints → init { require(...) } blows up in generation
  4. unsupported param type → runtime failure, not compile error
  5. great for codec round-trips, wrong for domain types

basics

~20 s

The reified Arb.bind<T>() form inspects the class's primary constructor and resolves a generator for each parameter from Kotest's built-in defaults. It is convenient for throwaway or wide DTOs, but it ignores your domain constraints and fails at runtime for parameter types it has no default for.

solid answer

~50 s

`Arb.bind<T>()` with a reified type parameter builds a generator by reflection: it looks at the primary constructor and, for each parameter, uses a built-in generator for that type. One line, no field wiring: ```kotlin val dtoArb = Arb.bind<CustomerDto>() ``` Three limits make it a convenience tool rather than the default: 1. **No domain constraints.** Every `Int` is any `Int`, every `String` any `String`. If the type's `init` block validates (age in 0..120, non-blank name), generation itself starts throwing — and the exception comes from the generator, not from your assertion. 2. **Unsupported types fail at runtime.** A parameter whose type has no built-in generator (a third-party value type, an interface, a sealed parent) fails when the generator runs, not at compile time. 3. **No invariants between fields.** It cannot know that `endDate` must follow `startDate`. So: fine for wide, constraint-free DTOs and serialization round-trip tests; for domain types write explicit `Arb.bind(...)` so the generated values are legal by construction.

code

kotlin · 6 lines
kotlin
data class CustomerDto(val id: String, val name: String, val age: Int, val active: Boolean)

// One line — good for a codec round-trip where field content is irrelevant
checkAll(Arb.bind<CustomerDto>()) { dto ->
   Json.decodeFromString<CustomerDto>(Json.encodeToString(dto)) shouldBe dto
}

go deeper

for a junior

Know that a one-line reflective form exists and that it fills constructor parameters from Kotest's built-in generators for common types.

for a middle

State the two practical limits — no domain constraints, runtime failure on types with no default — and name the round-trip test as its good use case.

for a senior

Contrast relevance versus mechanics: the shrinking works, but shrunk values outside your legal domain teach you nothing; explicit generators document the input space.

for a principal

Make it a convention: reflective bind allowed for wire DTOs and codec round-trips, explicit constrained generators required for validated domain types, reviewed alongside the type's invariants.

## What the reflective form does kotest-property has a reified overload of `bind` that takes no generator arguments: ```kotlin data class CustomerDto(val id: String, val name: String, val age: Int, val active: Boolean) val dtoArb: Arb<CustomerDto> = Arb.bind() ``` It reflects over the class's primary constructor and resolves a generator per parameter from Kotest's built-in defaults for common types (strings, numeric types, booleans, and similar). The appeal is obvious: a twenty-field DTO becomes one line, and adding a field to the DTO does not force a test edit. ## Why it is a convenience, not a default ### It knows nothing about your domain A generator's job is to produce values from the space your code is supposed to accept. Reflection can only see types. `age: Int` becomes the whole `Int` range including negatives and `Int.MIN_VALUE`; `email: String` becomes arbitrary text, never something shaped like an e-mail. Two failure modes follow: - **The generator throws.** If the class validates in `init` — `require(age in 0..120)` — the exception surfaces during generation. The test does not fail with a useful counterexample; the run blows up inside the framework's generation step, which is a confusing first experience. - **The property is vacuous.** If your code rejects everything the generator produces, the property tests only the rejection branch. Green, and worthless. ### Unsupported parameter types fail at runtime The resolution is by type, at run time. A constructor parameter whose type has no built-in generator — your own value class, a `java.time` type Kotest does not default, an interface or sealed parent that cannot be instantiated — has no default to resolve. You find out when the test runs, not when it compiles, and the error names a type rather than pointing at the test that needs fixing. Explicit `Arb.bind(arb1, arb2) { … }` is checked by the compiler instead. ### It cannot express relationships All components are sampled independently, so cross-field invariants (`total == lines.sum()`, `end > start`) are impossible by construction. Those need `arbitrary { }` or `flatMap`. ### Constructor coupling Because resolution is positional over the primary constructor, adding or reordering constructor parameters silently changes what is generated. That is usually harmless for a DTO and dangerous for a domain type where the fields carry meaning. ## Where it genuinely earns its keep - **Serialization round-trips.** `checkAll(Arb.bind<Dto>()) { dto -> decode(encode(dto)) shouldBe dto }` — you want arbitrary field content precisely because the codec should not care. - **Wide, dumb DTOs and wire records** with no validation, where writing twenty field generators is pure noise. - **Smoke coverage** while exploring, before you know which fields matter. - **Mapper tests**, where the property is "every field survives the mapping". ## The upgrade path Start reflective if it helps, and replace it with an explicit generator the moment the type acquires constraints. The explicit form documents the legal input space, which is itself valuable: reading `Arb.bind(Arb.string(minSize = 1, maxSize = 64), Arb.int(0..120)) { … }` tells the next engineer what a valid customer is, whereas `Arb.bind<Customer>()` tells them nothing. When only one field is problematic, keep the rest explicit rather than reaching for a partial-reflection trick — the explicit call site is short enough. ## A note on shrinking and edge cases The reflective form still uses real generators underneath, so the components contribute their own edge cases and shrinkers. The problem is never mechanical quality; it is *relevance*. Edge cases for the whole `Int` range are the wrong edge cases when your domain is `0..120`, and a shrunk counterexample of `age = -2147483648` tells you nothing you did not already know about a field that can never be negative in production. ## Interview framing Interviewers ask this to see whether you distinguish convenience from correctness. The strong answer: it exists, it resolves constructor parameters by type from built-in defaults, it is great for round-trip tests on constraint-free DTOs, and it is the wrong tool for validated domain types because it generates illegal values and fails at run time on types it cannot resolve.

  • A property using the reflective Arb.bind on a validated data class fails with an IllegalArgumentException before any assertion runs. What is happening and how do you fix it?
    The reflective generator produced a value the class's init block rejects — a negative age, a blank name — so the exception is thrown while the generator constructs the object, not by your property. Replace it with an explicit Arb.bind whose component generators are constrained to the legal domain (Arb.int(0..120), Arb.string(minSize = 1)), so every sample is constructible by design.

saying these in an interview costs you the question

  • Reaching for reflective bind as the default generator for domain types
  • Assuming it reads validation annotations or init-block requirements
  • Believing an unresolvable parameter type is caught at compile time
  • Explaining a nonsense counterexample (age = Int.MIN_VALUE) as a real production bug
  • Thinking it can honour cross-field invariants because it "knows the class"

context