Skip to content

Data-Oriented Programming with TypeScript Generics

Core Concept LearningAugust 3, 20266 min read

An Order class in a typical object-oriented codebase looks reasonable in isolation: private fields, a pay() method, a ship() method, validation baked into the constructor. It stops looking reasonable the day you need to serialize that order to send it to a background worker, and discover the class's private state and methods don't survive JSON.stringify — you get a plain object with the public fields and none of the behavior, and now there are two different representations of "an order" depending on which side of a queue you're standing on.

Data-oriented programming avoids that split by keeping data as plain, immutable, serializable objects and behavior as separate pure functions that operate on that data. TypeScript's generics and discriminated unions are what make this style type-safe rather than a return to untyped JavaScript objects passed around by convention. This guide follows one order through both styles — class-oriented and data-oriented — then builds a generic Entity type and an exhaustive state-transition switch that the compiler, not a runtime test, refuses to let you ship incomplete. For the broader pattern language this sits inside, see TypeScript advanced patterns.

Class-oriented vs. data-oriented modeling of the same order
Class-oriented vs. data-oriented modeling of the same order

Two ways to model the same order

A class-oriented Order binds data and behavior into one object: pay() and ship() live on the instance, private fields protect internal state, and correctness relies on every caller going through the class's methods rather than touching fields directly. This works cleanly inside one process, but the moment the order needs to cross a boundary — serialized into a queue message, stored as a database row, sent over HTTP — the class disappears. What arrives on the other side is a plain object with the public data and none of the methods, and now two different code paths need to know how to handle "an order," one with behavior and one without.

A data-oriented Order is a plain, immutable object with no methods at all — payOrder(order) and shipOrder(order) are separate functions that take an order and return a new order. There is exactly one representation of an order, and it survives a queue, a database round-trip, or a network call unchanged, because it was never anything more than data to begin with.

Quick reference

  • Class-oriented: behavior and state bound together; correctness depends on callers never bypassing the class's methods.
  • Data-oriented: data is plain and immutable; behavior lives in separate pure functions that take data in and return new data out.
  • Serialization is the forcing function — a class loses its methods across a JSON boundary; a plain object doesn't, because it never had any to lose.
  • This is not "OOP is wrong" — it's a scoping decision: keep classes for things with genuine internal invariants (a connection pool), and plain data for things that cross process boundaries.

Remember this

A class's behavior disappears the moment its data crosses a serialization boundary — data-oriented modeling avoids the mismatch entirely by never attaching behavior to the data in the first place.

Generics as a shape contract, not an identity

A generic Entity type lets many different kinds of data share a common contract (an id, a version, a createdAt) without inheritance or a shared base class. Unlike a class hierarchy, where Order extends BaseEntity ties every entity to one rigid ancestor, a generic type is structural — anything with the right shape satisfies it, and adding a new entity kind never requires touching an existing one.

The practical payoff shows up in functions that operate generically across entities: a function that bumps an entity's version and stamps an updatedAt field can be written once, typed as operating on Entity of T, and reused for orders, users, or invoices without any of them needing to inherit from anything.

One order moving through pure transform functions
One order moving through pure transform functions

Quick reference

  • Entity of T composes a shared shape (id, version, updatedAt) with type-specific fields, without an inheritance chain.
  • Pure functions (payOrder, touch) return new objects rather than mutating in place — this is what keeps data immutable and safe to share across async boundaries.
  • A generic function written once against Entity of T works for every entity kind, unlike a base-class method that only works for that hierarchy.
  • Guard invalid transitions explicitly (throw on an unexpected starting status) rather than trusting every caller to check first.
Class hierarchy — rigid, behavior-bound
1abstract class BaseEntity {2  constructor(public id: string, public version: number) {}3  abstract touch(): void;4}5 6class OrderEntity extends BaseEntity {7  status: "placed" | "paid" | "shipped" = "placed";8  touch() { this.version++; }9  pay() { this.status = "paid"; this.touch(); }10}11// Serializing an OrderEntity instance loses the touch/pay methods -12// the receiver gets { id, version, status }, nothing else.
Generic data shape + pure functions
1type Entity<T> = T & { id: string; version: number; updatedAt: string };2 3type Order = Entity<{4  status: "placed" | "paid" | "shipped";5  total: number;6}>;7 8function touch<T>(entity: Entity<T>): Entity<T> {9  return { ...entity, version: entity.version + 1, updatedAt: new Date().toISOString() };10}11 12function payOrder(order: Order): Order {13  if (order.status !== "placed") throw new Error("invalid_transition");14  return touch({ ...order, status: "paid" });15}16 17// Expected: payOrder(order) returns a new Order object;18// JSON.stringify(payOrder(order)) round-trips with every field intact.19// Break it: call payOrder on an order already "shipped" -20// expected: it throws invalid_transition instead of silently corrupting state.

Remember this

A generic shape contract lets unrelated entity kinds share common fields and common functions without an inheritance chain — adding a new entity never requires modifying an existing one.

Discriminated unions and exhaustive narrowing

An order's status is not just a string — it's a finite set of states, and the transitions between them are exactly the business logic worth protecting with the type system. A discriminated union (a status field with a fixed set of literal values) lets TypeScript narrow the type inside a switch statement, and an exhaustiveness check using the never type turns "we added a new status and forgot to handle it somewhere" from a runtime bug into a compile error.

This is the single most valuable technique in data-oriented TypeScript: model every meaningful state as a literal in a union, then let the compiler enumerate every place that switches on it. A new status added to the union will fail to compile everywhere it isn't handled, before a single test runs.

Zoom: exhaustive narrowing on an OrderState union
Zoom: exhaustive narrowing on an OrderState union

Quick reference

  • Model every meaningful state as a literal in a union, not as a loose string type.
  • An assertNever(value) helper typed to accept never turns a missing switch case into a compile-time type error, not a silent default branch.
  • This catches the exact bug class that causes production incidents: a new status shipped in one place (the database migration) and forgotten in another (the UI label function).
  • Prefer this over runtime validation libraries for internal state machines — the compiler check is free and runs on every save, not just on a test run.
  • The same discriminated-union discipline underlies a well-typed API response contract — see Structured outputs with Pydantic, Zod, and Instructor for the request-boundary version of this pattern.
String status — no compiler help
1function describeOrder(status: string): string {2  switch (status) {3    case "placed": return "Order placed";4    case "paid": return "Payment received";5    case "shipped": return "On its way";6    default: return "Unknown status"; // silently swallows a typo or a new status7  }8}
Discriminated union + exhaustiveness check
1type OrderStatus = "placed" | "paid" | "shipped" | "refunded";2 3function assertNever(value: never): never {4  throw new Error("Unhandled case: " + JSON.stringify(value));5}6 7function describeOrder(status: OrderStatus): string {8  switch (status) {9    case "placed": return "Order placed";10    case "paid": return "Payment received";11    case "shipped": return "On its way";12    case "refunded": return "Refunded";13    default: return assertNever(status);14  }15}16 17// Expected: adding "refunded" to the union without adding its case18// above fails to compile - TypeScript can't assign a leftover string19// literal to the never parameter of assertNever.20// Break it: comment out the "refunded" case - the file no longer type-checks.

Remember this

A discriminated union plus an exhaustiveness check moves a whole class of "forgot to handle the new state somewhere" bugs from a runtime incident to a compiler error the moment the union changes.

When to reach for this style, and when not to

Data-oriented modeling earns its keep for anything that crosses a serialization boundary — API payloads, queue messages, database rows, Redux-style state — because plain data survives those boundaries without translation layers. It's a worse fit for genuinely stateful, encapsulated components with real invariants to protect internally, like a connection pool or a caching layer with eviction logic — those benefit from a class's ability to hide implementation details behind a narrow public interface.

The test is not "is this object-oriented or functional" as an ideology — it's "does this data need to leave the process," and if the answer is yes, model it as plain data with pure functions from the start rather than retrofitting a class later once the serialization mismatch causes its first production bug.

Quick reference

  • Use plain data + pure functions for anything serialized: API bodies, queue messages, database rows, cache values.
  • Keep classes for components with real internal invariants to protect: connection pools, caches, stateful clients with lifecycle methods.
  • Discriminated unions plus exhaustiveness checks apply regardless of style — they're a TypeScript technique, not exclusive to data-oriented code.
  • Retrofitting this pattern onto an existing class-based Order is usually cheaper than it looks — extract the fields into a plain type, move each method into a same-named function taking that type as its first argument.

Remember this

The deciding question is whether the data needs to survive leaving the process, not a stylistic preference — anything serialized belongs in plain, immutable data with pure functions from the start.

Key takeaway

Build the Entity of T type and the Order type on top of it, then implement payOrder and shipOrder as pure functions with explicit invalid-transition guards. Confirm success by round-tripping an order through JSON.stringify and JSON.parse and verifying every field survives intact.

Then add a fourth status - refunded - to the OrderStatus union without updating describeOrder's switch, and confirm the file fails to compile at the assertNever call. That's your pass criterion: the compiler, not a runtime test, is the one that catches the missing case, and payOrder throws explicitly rather than silently corrupting state when called on an order in the wrong status.

Share:

Related Articles

A codebase full of any and as uses TypeScript as punctuation, not evidence. The useful patterns are the ones that preser

Read

Connecting Large Language Models to backend databases and business logic requires strict type safety. Receiving unstruct

Read

Next.js 16 continues the evolution of web application architecture, refining React Server Components (RSC), introducing

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.