Skip to main content

Type-safe data tables

How SpecBinder turns Gherkin tables into typed Java parameters instead of string maps

Gherkin Data Tables are one of the format’s nicest features.

They let you describe a small set of structured examples without turning the scenario into a wall of repeated steps.

Given my cart contains the following items:
| name | qty | price | category |
| Wireless Headphones | 1 | 60.00 | electronics |
| Coffee Beans 1kg | 2 | 15.50 | grocery |

Readable. Compact. Neatly aligned.

Very satisfying, assuming your editor behaves and the pipes stay where they are supposed to stay.

But then the table reaches the step implementation, and in many Gherkin automation stacks, the structure disappears.

The table that looked like domain data becomes a framework object, a list of maps, or a list of strings.

The feature file says “items”.

The Java code says “good luck parsing this”.

The problem with string-based table rows

In Cucumber-style runners, a data table is often handled as one of these shapes:

  • DataTable
  • List<Map<String, String>>
  • List<List<String>>

That is flexible.

It is also very stringly typed.

A typical step implementation might look like this:


@Given("my cart contains the following items:")
public void myCartContainsTheFollowingItems(DataTable table) {
List<Map<String, String>> rows = table.asMaps();

for (Map<String, String> row : rows) {
String name = row.get("name");
Integer qty = Integer.valueOf(row.get("qty"));
Double price = Double.valueOf(row.get("price"));
Category category = Category.valueOf(row.get("category"));

cart.add(new Item(name, qty, price, category));
}
}

This works.

Until one of the strings stops agreeing with another string.

Rename the name column to product in the feature file, and row.get("name") does not become a compiler error.

It becomes null.

Change qty to quantity, same story.

Write elecronics instead of electronics, and Category.valueOf(...) waits until the scenario runs before it has an opinion.

Very calm.

Very late.

The table has a schema, but the schema lives in several places at once:

  • the headers in the .feature file
  • the string keys in the step implementation
  • the parsing code inside the loop
  • any domain conversion rules hidden nearby

The compiler cannot connect those pieces.

So the code compiles even when the table shape and the Java implementation have drifted apart.

What SpecBinder changes

SpecBinder treats Data Tables as part of the generated step contract.

The table is not just an object passed through to your step method. Its rows can become generated Java parameter objects.

That is the default Data Table mode: LIST_OF_OBJECT_PARAMS.

For this Gherkin step:

Given my cart contains the following items:
| name | qty | price | category |
| Wireless Headphones | 1 | 60.00 | electronics |
| Coffee Beans 1kg | 2 | 15.50 | grocery |

SpecBinder can generate a step method that receives a typed list:

public void myCartContainsTheFollowingItems(List<ItemsParam> items) {
Assertions.fail("Step is not yet implemented");
}

And it can generate a parameter class for the table rows:

public static class ItemsParam {
private final String name;
private final Integer qty;
private final Double price;
private final String category;

public ItemsParam(
String name,
Integer qty,
Double price,
String category
) {
this.name = name;
this.qty = qty;
this.price = price;
this.category = category;
}

public String name() {
return this.name;
}

public Integer qty() {
return this.qty;
}

public Double price() {
return this.price;
}

public String category() {
return this.category;
}
}

The generated test method for the gherkin scenario then calls the step with real Java objects:

myCartContainsTheFollowingItems(List.of(
new ItemsParam("Wireless Headphones", 1, 60.00, "electronics"),
new ItemsParam("Coffee Beans 1kg", 2, 15.50, "grocery")
));

That changes the feel of the step implementation.

Instead of manually decoding rows, your code can work with typed accessors:


@Override
public void myCartContainsTheFollowingItems(List<ItemsParam> items) {
for (ItemsParam item : items) {
cart.add(new Item(
item.name(),
item.qty(),
item.price(),
item.category()
));
}
}

No Map.get("qty").

No Integer.valueOf(row.get("qty")).

No table parsing ceremony hiding inside every step.

The step receives the shape the scenario described.

Which is a surprisingly pleasant thing for test code to do.

The method signature becomes documentation

With a raw table, the method signature tells you almost nothing:

public void myCartContainsTheFollowingItems(DataTable table)

That says there is a table.

Wonderful.

What columns does it have? What types do the values become? Is price a String, a Double, a BigDecimal, or a domain-specific Money value?

You have to read the method body, the feature file, and possibly a registered transformer to find out.

With generated object parameters, the signature carries more meaning:

public void myCartContainsTheFollowingItems(List<ItemsParam> items)

And the parameter type describes the row:

public static class ItemsParam {
public String name() { ...}

public Integer qty() { ...}

public Double price() { ...}

public String category() { ...}
}

That is easier to read.

It is also easier for Java tooling to understand.

Autocomplete can show available accessors. Rename refactoring has actual methods to work with. If generated accessors change, implementation code using the old shape stops compiling.

The table is no longer just “some strings from a feature file”.

It is part of the Java API generated from that feature file.

Column values are inferred into Java types

SpecBinder can infer table column types from the cell values.

The rules are the same kind of rules used for quoted step parameters.

Cell valuesInferred column type
true / false onlyBoolean
integer literals fitting in intInteger
integer literals too large for intLong
decimal literalsDouble
single charactersCharacter
anything elseString

So this table:

Given my cart contains the following items:
| name | qty | price | active |
| Wireless Headphones | 1 | 60.00 | true |
| Coffee Beans 1kg | 2 | 15.50 | false |

can produce fields like this:

public static class ItemsParam {
private final String name;
private final Integer qty;
private final Double price;
private final Boolean active;

// constructor and accessors
}

That already removes a lot of repetitive parsing.

But the more interesting part comes next.

You can refine the generated parameter class

Generated types are useful on their own.

But the real benefit appears when you refine them into domain types.

Suppose category should not be any string. It should be one of the categories your system actually supports.

You can move the generated parameter class into your marker or base class and refine the field type:

public abstract class CartFeature {

public enum Category {
electronics,
grocery,
books
}

public static class ItemsParam {
private final String name;
private final Integer qty;
private final Double price;
private final Category category;

public ItemsParam(
String name,
Integer qty,
Double price,
Category category
) {
this.name = name;
this.qty = qty;
this.price = price;
this.category = category;
}

public String name() {
return this.name;
}

public Integer qty() {
return this.qty;
}

public Double price() {
return this.price;
}

public Category category() {
return this.category;
}
}

public void myCartContainsTheFollowingItems(List<ItemsParam> items) {
for (ItemsParam item : items) {
cart.add(new Item(
item.name(),
item.qty(),
item.price(),
item.category()
));
}
}
}

On the next build, SpecBinder can use your refined ItemsParam instead of generating a fresh one.

Now the table contract is stronger.

The feature file still says:

| category    |
| electronics |
| grocery |

But the Java constructor expects Category, not String.

So the generated call can use enum constants:

myCartContainsTheFollowingItems(List.of(
new ItemsParam("Wireless Headphones", 1, 60.00, Category.electronics),
new ItemsParam("Coffee Beans 1kg", 2, 15.50, Category.grocery)
));

That is the important shift.

The table value is no longer parsed inside the step body. It is rendered into Java at the generated call site.

If it can be represented as the target type, the generated code compiles.

If it cannot, the compiler gets involved.

The compiler enjoys getting involved.

Invalid table values become compile errors

Now imagine someone adds a new row:

Given my cart contains the following items:
| name | qty | price | category |
| Wireless Headphones | 1 | 60.00 | electronics |
| Coffee Beans 1kg | 2 | 15.50 | grocery |
| Yoga Mat | 1 | 25.00 | sports |

But your enum only allows this:

public enum Category {
electronics,
grocery,
books
}

There is no Category.sports.

So SpecBinder cannot render sports as a valid enum constant.

The generated call becomes invalid Java:

myCartContainsTheFollowingItems(List.of(
new ItemsParam("Wireless Headphones", 1, 60.00, Category.electronics),
new ItemsParam("Coffee Beans 1kg", 2, 15.50, Category.grocery),
new ItemsParam("Yoga Mat", 1, 25.00, "sports")
));

The last constructor call passes a String where Category is expected.

That does not compile.

And that is exactly the point.

The team must now make a deliberate decision:

  • add sports to the domain enum
  • fix the table value
  • change the expected parameter type if the value really should be free-form text

What does not happen is a late IllegalArgumentException from Category.valueOf(...) halfway through a test run.

The mismatch is visible during compilation.

Much better timing.

The table stays readable

One nice part of this model is that the Gherkin table does not have to become Java-shaped.

The feature file can stay business-readable:

| name                | qty | price | category    |
| Wireless Headphones | 1 | 60.00 | electronics |

The Java side can still be typed:

new ItemsParam(
"Wireless Headphones",
1,
60.00,
Category.electronics
)

That separation matters.

Gherkin should remain a good format for examples.

Java should remain a good language for implementation.

SpecBinder’s job is not to make one pretend to be the other.

It is to generate a useful bridge between them.

Readable on one side. Typed on the other.

That is the happy place.

When the strict default is not the right fit

Typed object parameters are the default because they are usually the safest and most useful shape.

But not every table wants to be a typed row object.

Sometimes the table is intentionally dynamic. Sometimes it represents arbitrary key-value data. Sometimes you already have existing Cucumber table transformation code and do not want to migrate it immediately.

SpecBinder provides opt-in alternatives through @Gherkin2JUnitOptions.

You can ask for a Cucumber DataTable:


@Gherkin2JUnitOptions(
dataTableParameterType = DataTableParameterType.CUCUMBER_DATA_TABLE
)
public abstract class CartFeature {
// step methods can receive DataTable
}

Or you can ask for a list of maps:


@Gherkin2JUnitOptions(
dataTableParameterType = DataTableParameterType.LIST_OF_MAPS
)
public abstract class CartFeature {
// step methods can receive List<Map<String, String>>
}

Those modes are useful escape hatches.

The important part is that they are explicit.

If you want the raw table, you can have it. If you want the stricter generated object model, keep the default.

What this does not mean

Type-safe Data Tables do not prove that your scenario is a good scenario.

They do not tell you whether the example is meaningful. They do not stop someone from writing a table with seventeen columns, three hidden concepts, and the emotional weight of a database export.

Please do not do that.

They also do not remove all runtime failures.

Assertions still fail at runtime. Application behavior is still checked when the test runs. If the cart calculates the wrong subtotal, the test should fail as a test.

What typed Data Tables catch earlier are structural mismatches:

  • column values that cannot be rendered as the expected Java type
  • enum values that are not part of the domain model
  • implementation code that still expects an old generated accessor
  • table shapes that no longer match the generated or refined parameter class
  • duplicated parsing logic that no longer needs to exist

There are also practical trade-offs:

  • initial generated parameter class names may be mechanical, but can be renamed when you move and refine the class in your base type
  • table headers do not need to look like Java fields, because SpecBinder sanitizes them into Java-friendly names. They just need to remain clear enough to produce unambiguous accessors
  • inferred column types are based on cell values; domain-specific meaning may still need an explicit refined parameter class
  • refined parameter classes become real Java types, so they should evolve with the table contract

The table can stay readable, but it still needs to behave like structured data if you want the compiler to help.

That is the trade.

For automation code, that is usually a very good bargain.

Why it matters

String-based Data Tables create small costs that repeat.

A Map.get("price") here. A Double.valueOf(...) there. A column rename that compiles cleanly and fails later. An enum typo that waits until CI to introduce itself.

Each problem is small.

Together, they make the suite feel more fragile than it needs to be.

SpecBinder moves that table structure into generated Java code.

A Data Table can become a list of typed row objects. Generated accessors make the shape visible. Refined parameter classes let you use domain types. Invalid values become compiler feedback instead of late parsing failures.

That is the same idea behind SpecBinder more broadly:

readable Gherkin → generated Java contract → ordinary compiler feedback

The feature file stays readable.

The test code becomes more type-safe.

The pipes can stay.

The stringly typed plumbing can go.