DEV Community

Cover image for Lombok @Builder vs TypeScript: Why You Don't Need a Builder Pattern
Gabriel Anhaia
Gabriel Anhaia

Posted on

Lombok @Builder vs TypeScript: Why You Don't Need a Builder Pattern


A Java engineer pairs with a Node team for the first time. They open the order service, see a function that constructs an Order, and pause. The function takes a single object literal — userId, items, shipping, currency, notes. No Order.builder(), no fluent chain, no .build() at the bottom. They ask the obvious question: where is the builder?

There is no builder. There will not be a builder. The Java reflex translates the structure straight across: public class, private fields, a @Builder annotation, twelve setters returning this, a final build() calling a private constructor. Dead code ships.

The builder pattern in Java exists to compensate for two missing language features: named arguments and optional parameters with defaults. Lombok's @Builder is a compile-time code generator that papers over the gap. TypeScript already has both at the language level, plus a structural type system that catches bad fields at the call site without erasing the type. Carrying the builder habit into TS produces APIs that read worse than what the language gives you for free.

This is a JVM-mindset shift. Java draws the line at twelve parameters; TypeScript draws it at type-level state.

What @builder is solving

A Java constructor with twelve parameters is unreadable at the call site:

new Order(
    1234L,
    List.of(itemA, itemB),
    "EU",
    "USD",
    null,
    null,
    Instant.now(),
    OrderSource.WEB,
    null,
    "promo-fall",
    false,
    null
);
Enter fullscreen mode Exit fullscreen mode

You cannot tell which null is which field. Reordering two parameters of the same type compiles cleanly and ships the wrong shipping zone. Adding a thirteenth parameter forces every call site to add a token, even if the new field has a sensible default.

@Builder is the official-looking workaround. Lombok generates a static nested OrderBuilder class, one fluent setter per field, plus a build() that calls the private constructor. The call site reads as labels:

Order.builder()
    .userId(1234L)
    .items(List.of(itemA, itemB))
    .shippingZone("EU")
    .currency("USD")
    .promoCode("promo-fall")
    .build();
Enter fullscreen mode Exit fullscreen mode

The chain buys names at every step (so reordering is impossible), optional fields (unset methods leave the field at its default), and per-field defaults via @Builder.Default annotations on the class. The Lombok @Builder reference covers the generated shape and @Builder.Default semantics. None of that machinery is wrong — it is a sensible response to a missing language feature. TypeScript does not have that constraint.

TS already has named arguments

A TypeScript object literal is a positional-argument-free zone. The compiler binds by property name, not by position. Reordering fields cannot mean anything because there is no order.

type Order = {
  userId: number;
  items: ReadonlyArray<{ sku: string; qty: number }>;
  shippingZone: "EU" | "US" | "APAC";
  currency: "USD" | "EUR" | "GBP";
  promoCode?: string;
  notes?: string;
};

function createOrder(input: Order): Order {
  return { ...input };
}

const order = createOrder({
  userId: 1234,
  items: [{ sku: "A1", qty: 2 }, { sku: "B7", qty: 1 }],
  shippingZone: "EU",
  currency: "USD",
  promoCode: "promo-fall",
});
Enter fullscreen mode Exit fullscreen mode

That call site has every property named. The compiler refuses missing required fields. Optional fields use ?: and can be omitted. There is no positional ambiguity, no chain, no terminal build(). Compare it to the Lombok version side by side:

Order.builder()
    .userId(1234L)
    .items(List.of(itemA, itemB))
    .shippingZone("EU")
    .currency("USD")
    .promoCode("promo-fall")
    .build();
Enter fullscreen mode Exit fullscreen mode
createOrder({
  userId: 1234,
  items: [{ sku: "A1", qty: 2 }, { sku: "B7", qty: 1 }],
  shippingZone: "EU",
  currency: "USD",
  promoCode: "promo-fall",
});
Enter fullscreen mode Exit fullscreen mode

Same names. Same fields. Half the tokens. No generated nested class.

A JVM dev reading TS for the first time will sometimes write the builder anyway — a class with a private state object, methods returning this, a final build() returning a frozen copy. The call site looks identical to the literal except for the wrapping .build(). The cost is an extra file, an extra type, and an extra place a future developer can put a typo.

Defaults are a destructure, not an annotation

Lombok's @Builder.Default is the answer to "this field should be Currency.USD if the caller does not set it." TypeScript has the same answer in three shapes, and you pick by feel.

The first is destructure-defaults at the function boundary:

function createOrder({
  userId,
  items,
  shippingZone,
  currency = "USD",
  promoCode,
  notes,
}: Order): Order {
  return { userId, items, shippingZone, currency, promoCode, notes };
}
Enter fullscreen mode Exit fullscreen mode

currency = "USD" is the default. The caller can omit it. The body sees a defined currency. No annotation, no codegen, no nested class.

The second is the nullish-assignment operator on a partially-typed input. Useful when you accept a wider input type than the resolved one:

type OrderInput = Partial<Order> & Pick<Order, "userId" | "items">;

function createOrder(input: OrderInput): Order {
  const filled: Order = {
    shippingZone: "US",
    currency: "USD",
    ...input,
  };
  filled.currency ??= "USD";
  return filled;
}
Enter fullscreen mode Exit fullscreen mode

The spread sets defaults that any explicit field overrides. ??= is the safety net for fields that survive as undefined after the spread. You will reach for this shape when the input type is intentionally loose and the resolved type is intentionally tight.

The third is the type-level default: required at the resolved type, optional at the input type, with a thin constructor turning one into the other.

type OrderInput = Partial<Pick<Order, "currency">> &
  Omit<Order, "currency">;

function createOrder(input: OrderInput): Order {
  return { currency: "USD", ...input };
}
Enter fullscreen mode Exit fullscreen mode

None of these forms need a class, a chain, or a builder. They are language features. They compose with the rest of TS (generics, conditional types, narrowing) in a way a Lombok builder cannot, because @Builder is a structural workaround glued on at compile time.

satisfies catches the bad-field case

The strongest argument JVM devs reach for is that the chain prevents typos. Order.builder().userIdd(1234L) does not compile, because userIdd is not a method on OrderBuilder. Surely the same typo would compile in a plain object literal?

It does not. TypeScript checks excess properties on object literals against the contextual type. Pass the literal directly to a function whose parameter type is Order, and userIdd is flagged. The bug Java engineers worry about (silently dropping a property because the field name does not match) was solved by excess property checking in the early days of TS.

The harder case is when the literal is assigned to a variable first, then used. The compiler widens the type to whatever the literal contains, the bad field comes along for the ride, and downstream code never reads it.

const config = {
  retries: 3,
  timeout: 1000,
  retires: 5, // typo. Compiles fine. Never read.
};

function withRetries(c: { retries: number; timeout: number }) {
  return c;
}

withRetries(config); // also fine. The typo is silently extra.
Enter fullscreen mode Exit fullscreen mode

satisfies is the operator for catching this without erasing the literal type. It asserts the value conforms to a constraint while keeping the inferred shape:

type Config = { retries: number; timeout: number; mode?: "strict" };

const config = {
  retries: 3,
  timeout: 1000,
  retires: 5, // error: Object literal may only specify known properties
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

The compiler now refuses the typo at the literal site. config keeps the narrow inferred shape: mode does not become "strict" | undefined, it stays absent, which preserves the precise type the writer wrote. The TS 4.9 release notes introduce satisfies and walk through the narrowing-vs-widening trade-off.

For a JVM dev, the mental swap is simple. satisfies catches typos; it does not narrow types for you. Object literals get named arguments and excess-property checking; satisfies extends those guarantees from the call site to the variable-declaration site. The combination covers what Order.builder() covers in Java, with no class to write.

The one place TS does benefit from "builder-shaped" code

This post is about data construction — building a value-typed object. One shape where TypeScript genuinely benefits from a fluent builder API is worth naming so the JVM-to-TS port lands on the right boundary.

That shape is the fluent DSL, where the chain accumulates type-level information across each call instead of constructing a single object. Drizzle's query builder works this way. The initial .select(...) shape determines the result row type, each .leftJoin() widens it with the joined columns, and .where() / .orderBy() accumulate query state the chain carries forward into .execute(). The chain is load-bearing because the types depend on the order. A function taking one big config object would have to encode the same constraint in the parameter type, and the inference would suffer.

The same shape appears in tRPC's router builder: each .input(), .use(), .query() accumulates the procedure's input schema, middleware context, and output type. The chain returns objects whose generic parameters are the previous chain's output. By the time .query() is called, the handler's ctx and input types are exactly what the chain accumulated. The chain expresses type-level state flowing from one call to the next, which a plain object literal cannot do.

For step-by-step value construction ("required A, then required B, then optional C"), the same machinery does the work. A typed builder that requires the caller to set certain fields before .build() becomes available is one of the rare places a TS API benefits from a chain. The phantom-state pattern that powers it gets its own post (Phantom Types in TypeScript), covered separately.

A Validated brand is the closest thing to "I came out of a builder"

When a Java engineer says "the builder produced a valid Order," the implicit guarantee is that downstream code does not need to re-check invariants. Lombok does not enforce this. @Builder will happily build an Order with a negative userId. Convention puts validation on the builder.

TypeScript's structural type system makes "same shape but came out of a validation function" hard to express by default. A ValidatedOrder and a RawOrder with the same fields are structurally equivalent. The fix is a branded type: a type carrying a phantom property the structural system cannot accidentally produce.

type Brand<T, B> = T & { readonly __brand: B };
type ValidatedOrder = Brand<Order, "Validated">;

function validateOrder(order: Order): ValidatedOrder | string[] {
  const errors: string[] = [];
  if (order.userId <= 0) errors.push("userId must be positive");
  if (order.items.length === 0) errors.push("items required");
  if (errors.length > 0) return errors;
  return order as ValidatedOrder;
}

function shipOrder(order: ValidatedOrder) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

shipOrder will not accept a raw Order. The only way to get a ValidatedOrder is through validateOrder. The brand is invisible at runtime (a property with a name nothing in user code writes), but the type system treats it as load-bearing. This is the closest thing TypeScript has to a builder's "I produced an Order with all invariants checked" guarantee, and it does not require a class. The Effect docs on Brand cover the runtime-zero version of the pattern.

What the JVM-to-TS port actually looks like

A team migrating an order service from Spring to a Node backend hits this immediately. The Java code has OrderRequest, OrderRequestBuilder, OrderRequest.builder().userId(...).build(). The naive port lifts the builder class straight across — twice the surface area for nothing. The port matching the language collapses to a single input type, a factory that validates, and a brand that survives:

type OrderInput = {
  userId: number;
  items: ReadonlyArray<{ sku: string; qty: number }>;
  shippingZone: "EU" | "US" | "APAC";
  currency?: "USD" | "EUR" | "GBP";
  promoCode?: string;
};

type ValidatedOrder = Brand<Required<OrderInput>, "Validated">;

function buildOrder(input: OrderInput): ValidatedOrder | string[] {
  const filled = {
    currency: "USD" as const,
    ...input,
  } satisfies Required<OrderInput>;

  const errors: string[] = [];
  if (filled.userId <= 0) errors.push("userId must be positive");
  if (filled.items.length === 0) errors.push("items required");
  if (errors.length > 0) return errors;

  return filled as ValidatedOrder;
}
Enter fullscreen mode Exit fullscreen mode

OrderInput is the public shape, ?: marks the optional fields, satisfies catches typos in the defaults block, and the brand carries the validation result. No class, no chain, no @Builder.

A reviewer reads top-to-bottom and sees shape, defaults, invariants, and brand in that order. The Lombok version splits the same content across an annotation, a generated nested class, a builder reference, and a build() call that runs at a different time than the field assignments.

The point is not that builders are bad. They are a tool that earned its place in Java because the language did not give you the alternative. TypeScript gives you the alternative. Reach for the builder when you need a fluent DSL with type-level state flowing through the chain. Reach for an object literal when you need to construct a value. The JVM reflex blurs that line. The TS habit keeps it sharp.

Open the next *Builder class in your TypeScript codebase. If the chain only sets fields, delete it and replace the call sites with object literals plus satisfies. If it accumulates types (Drizzle, tRPC, a phantom-state DSL), keep it. The deletes will outnumber the keeps, and the diff is the part of the JVM-to-TS port that pays back the fastest.


If this was useful

The TypeScript Library is a 5-book collection. The book closest to this post is the bridge for JVM developers — Kotlin and Java to TypeScript — covering object-literal idioms, branded types, satisfies, generics, and the rest of the JVM-to-TS port chapter by chapter.

The TypeScript Library — the 5-book collection

  • TypeScript Essentials — entry point on types, narrowing, modules, async, daily-driver tooling: Amazon
  • The TypeScript Type System — deep dive into generics, mapped/conditional types, infer, template literals, branded types: Amazon
  • Kotlin and Java to TypeScript — the bridge for JVM developers, including the object-literal-vs-builder chapter this post sits next to: Amazon
  • PHP to TypeScript — the bridge for PHP 8+ developers: Amazon
  • TypeScript in Production — tooling, build, monorepos, library authoring across runtimes: Amazon

All five books ship in ebook, paperback, and hardcover.

Hermes IDE is the editor where most of this code was sketched — built for developers who ship with Claude Code and other AI coding tools: hermes-ide.com.

Top comments (0)