Choose Application Behavior and Ownership

TypeScript Classes vs Modules: Should State Belong to an Instance or a File?

Decide between a TypeScript class and a module by asking whether callers need separate state or just access to related functions.

Editorial illustration for TypeScript Classes vs Modules: Should State Belong to an Instance or a File?

If two callers need carts that change independently, give each caller a Cart instance. If callers only need a calculation that works from its inputs, export a function from a module. That is the useful distinction in TypeScript classes vs modules: where should state and behavior live?

A class defines objects you can instantiate. A module gives a file its own scope and lets you choose which declarations to export. They are not competing syntaxes—a module can export a class. The choice is whether a particular piece of state belongs to each object or whether related code belongs together at the file boundary. TypeScript’s module documentation describes imports and exports as module syntax; the class can be one of the declarations behind that boundary.

Give each cart its own state

Suppose a feature needs to track items for two separate carts. The operations are the same, but changes to one cart should not change the other:

type Item = { sku: string; quantity: number };

class Cart {
  private items: Item[] = [];

  add(item: Item): void {
    this.items.push(item);
  }

  getItemCount(): number {
    return this.items.length;
  }
}

const firstCart = new Cart();
const secondCart = new Cart();

firstCart.add({ sku: "notebook", quantity: 1 });

console.log(firstCart.getItemCount());  // 1
console.log(secondCart.getItemCount()); // 0

The two calls to new Cart() create two carts, each with its own items field. The final two lines are a small runtime check for the behavior this example needs: after adding an item to firstCart, secondCart should still report zero items. The point is not that every collection needs a class. It is that the collection and the operations that change it travel together per cart. A task-management class with private tasks and methods that act on them uses the same arrangement in this modules lesson.

Keep input-only calculations in a module

Now suppose pricing is calculated entirely from the lines passed to it. No caller needs a separately changing pricing object. A file can group the calculation with a helper that callers do not need to import:

// pricing.ts
type PriceLine = { unitPrice: number; quantity: number };

function lineTotal(line: PriceLine): number {
  return line.unitPrice * line.quantity;
}

export function calculateSubtotal(lines: PriceLine[]): number {
  return lines.reduce((total, line) => total + lineTotal(line), 0);
}
// checkout.ts
import { calculateSubtotal } from "./pricing";

const subtotal = calculateSubtotal([
  { unitPrice: 12, quantity: 2 },
  { unitPrice: 5, quantity: 1 },
]);

console.log(subtotal); // 29

checkout.ts imports the exported function, not lineTotal. The helper stays available to the implementation in pricing.ts without becoming part of the file’s importable surface. That is file-level encapsulation: unexported bindings remain inside the module, while exported bindings are available to importers. The modules lesson makes this distinction between a module’s private bindings and its public exports.

Ask who needs a separate copy

The decision test is: Do callers need independently changing copies of this state? If yes, instances are a reasonable place for those copies, as with the carts. If the work can be expressed in terms of inputs and a result, a module of related functions and private helpers may be enough, as with pricing. Either way, the file still organizes the code; you could export Cart from a cart module for other files to instantiate.

Do not treat a module-level variable as shorthand for an instance field. Its scope is the file, not a fresh cart for each caller. Module initialization and sharing depend on the runtime loader and project setup; TypeScript syntax alone does not promise a new copy on every import. Some loaders cache modules and reuse their exports, which is precisely why module-level mutable state deserves a deliberate decision rather than an accidental one.

A TypeScript check can help establish that the shown imports and calls match their declared types. It cannot establish that the runtime results above occurred, or that data arriving from outside the program has valid prices and quantities. If that data is external, check it when it enters the application; an annotation on PriceLine is not that check.

Find a note

Search by topic, title, or keyword.