When Not to Use Classes

Intermediate · 20 min read · ▶ live playground · ✦ checkpoint

Do not reach for a class just because your data has behavior. In TypeScript, plain object shapes plus functions often model everyday app code more directly, because the checker cares about structure, not class names.

Classes are not bad. They are just heavier than object shapes: they create runtime constructors, require new, introduce instance identity, and invite access modifiers and inheritance. Use that weight when it earns its place.

Start with data and functions

Most TypeScript application code moves data through small functions. A task record, a form state, a quiz answer, a checkout step — those are usually plain object shapes. Functions describe the changes.

The familiar tools from earlier sections are enough here: object types, literal unions, discriminated unions when states differ, and functions with annotated boundaries.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

No class is missing from this design. There is no hidden state, no constructor work, no runtime identity to preserve. The important promise is the shape: a Task has an id, title, and approved status. The important behavior is a set of functions that accept and return that shape.

This also makes testing and refactoring simple. You can call finishTask with a plain object, inspect the returned object, and move on. No setup object has to remember previous calls.

Where classes earn their keep

Classes are strongest when the runtime object itself matters.

Use a class when you have stateful services: an object that holds private mutable state across several method calls. Use a class for custom errors when instanceof gives you a real runtime check. Use a class when a framework contract asks for one: some tools expect a constructor, lifecycle methods, decorators, or subclassing hooks. In those cases, using a class is not ceremony; it is the contract.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

This class earns its keep. TaskStore owns nextId and the saved list across calls. A plain function could do this by passing state in and out every time, but the point of a service object is to remember. The custom error class earns its keep too: instanceof InvalidTaskTitleError is a real runtime check, not just a type annotation.

That is the decision rule: if you need identity, private state, constructor setup, instanceof, or an outside framework expects a class, use a class. If you only need to transform data, start smaller.

Side by side

Here is the same tiny feature both ways. The class version bundles data and methods behind new. The function version keeps the data as a plain object and puts behavior in named functions.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

Both versions are type-safe. The class version is not "more TypeScript." It may be useful if callers naturally think in terms of a Submission object with methods, or if you later need private state. But for this example, the function version says the truth with fewer moving parts: here is the data shape, here is the transformation, here is the label.

The best TypeScript code is not anti-class. It is honest about what the program needs. Plain data plus functions is often the default because it matches TypeScript's structural typing and keeps dependencies easy to see. Classes are reserved for the places where the runtime object earns a name, a constructor, and a lifecycle of its own.

Next section starts a new reusable-type pattern: preserving caller information instead of throwing it away.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion