Encapsulation hides an object's state behind its methods, abstraction shows what an object does and not how, inheritance lets a subclass reuse a class's code, and polymorphism lets one call work with any object of the right type. In TypeScript all four sit on top of JavaScript's prototypes.
Encapsulation: #balanceCents changes only through topUp() and pay(), so no caller can set a negative balance; a closure gives the same privacy without a class.
Abstraction: checkout code sees only pay(amount); tokenization and 3-D Secure stay inside the implementation.
Inheritance: PaymentError extends Error keeps message and the stack trace and adds status; methods are looked up along the prototype chain, not copied.
Polymorphism: method.pay(700) runs the wallet's or the card's code. TypeScript checks it structurally: any object with a matching pay fits.
ReactMiddle
OOP, SOLID & Design Patterns
Classes and OOP in TypeScript, SOLID, DRY, KISS and YAGNI, coupling and cohesion, GoF patterns in frontend code: observer, module, factory, strategy, adapter, facade, proxy, anti-patterns
Questions closed
0 / 20
- 0Review
- 7Asked often
Questions
private is a TypeScript check that disappears after compilation, so at runtime the field is an ordinary property. #private is a JavaScript private field that the engine enforces. protected also opens a member to subclasses, and readonly forbids assigning it after the constructor.
**private is soft**: acc['pin'] compiles (a deliberate escape hatch), and JSON.stringify and the console show the field.
**#private is hard**: unreachable outside the class body even through any and invisible to JSON; #token in value is a brand check, true only for real instances.
**The cost of #**: subclasses cannot read it, and methods that read # fields throw when called through a Proxy, such as Vue's reactive().
**protected and readonly** are compile-time only. readonly items: string[] stops reassigning the property, not items.push(); that needs readonly string[].
An interface is only a contract: a shape with no code that disappears after compilation. An abstract class is a real class you cannot instantiate: it can hold fields and implemented methods for its subclasses. Use an interface by default and an abstract class when subclasses share real code.
Interface: a class can implement several, and any object of the right shape fits, so a test passes an object literal as a fake.
Abstract class: subclasses must implement its abstract members and inherit the rest, including a constructor and protected helpers; instanceof works because the class exists at runtime.
Template method, the typical good use: ChunkedUploader.upload() fixes the algorithm, and subclasses fill in start() and sendChunk().
One base class only: an abstract base locks the hierarchy, so when the shared code is small, an interface plus a helper function leaves more freedom.
DRY (don't repeat yourself) says each piece of knowledge lives in one place, KISS (keep it simple) says pick the simplest design that works, and YAGNI (you aren't gonna need it) says do not build for needs you only imagine. Merging duplicates hurts when the copies only look alike.
DRY is about knowledge, not text: a password rule checked in two forms and the API will drift apart; one Zod schema shared by form and server keeps it in one place. Similar-looking JSX is not shared knowledge.
The wrong abstraction: two cards merged into one <Card> with isCompact, showPrice and variant="legacy" couple screens that change for different reasons; duplication is far cheaper than the wrong abstraction (Sandi Metz).
Rule of three: extract on the third copy, when you can see what actually varies.
KISS and YAGNI: a function before a class hierarchy, useState before a state library; no generic DataTable "for future tables" and no plugin system for one plugin, because code built for an imagined need rarely fits the real one.
Coupling is how much a module depends on the details of other modules; cohesion is how closely the code inside one module belongs together. Aim for low coupling and high cohesion: a change then stays in one place, and each module has one clear job.
Shotgun surgery: renaming one API field touches 30 components because each reads the raw response. Map responses to your own types in the API layer, and the change stops there.
Coupling to concrete things: a component that imports axios, localStorage or a store module directly cannot be reused or tested without them.
Hidden coupling: a shared global (window.__config) or an order rule ("call initAnalytics() before track()") links modules that no import connects.
Low cohesion: a utils.ts with dates, prices and permissions has no single job, so everything imports it; a features/checkout folder has one.
Measuring it: madge --circular --extensions ts,tsx src or dependency-cruiser show cycles and who imports what; a feature PR touching ten folders is the cheapest signal.
Inheritance ties a subclass to its parent's internals, so a change in the base class can break subclasses it has never seen, and a hierarchy allows one axis of variation. Composition holds parts and uses their public API. Extend when the platform expects it: Error, HTMLElement.
Fragile base class: the base addAll() calls add(), so CountingTagList, which overrides both, counts every tag twice. Nothing warns; the composed version is correct because it uses only the public methods.
Combinations explode: caching, logging and retries as subclasses need CachedLoggedClient and friends; as wrappers they combine freely: withCache(withRetry(client)).
Is-a vs has-a: a UserService that extends HttpClient to reuse get() exposes every HTTP method to its callers; hold the client in a field and delegate.
When extending is right: HttpError extends Error (for instanceof and the stack trace), a custom element, an error boundary that extends Component, a shallow abstract base with a template method.
SOLID is five design rules: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion. In TypeScript they apply to modules and functions as much as to classes.
S, single responsibility: an AuthService that calls the API, stores tokens and shows toasts changes for three reasons; split it into an API client, a token store and UI code.
O, open/closed: a validator that takes a list of rules gets maxLength as a new function, not as another if in validate().
L, Liskov substitution: a subtype keeps the parent's promises, not only its signatures: a MemoryStorage for tests returns null for a missing key, as localStorage does.
I, interface segregation: readToken asks for Pick<Storage, 'getItem'>, so a test passes a one-method object.
D, dependency inversion: createCheckout depends on a PaymentGateway interface it owns; the provider's code implements it, and the app's entry point connects the two.
The limit: split code that changes for different reasons, but do not add an interface for every class: one implementation and no fake means no interface yet.
It breaks the Liskov substitution principle: buyNow is written for any Cart and expects checkout() to create an order, while a GuestCart compiles and then throws. Fix the model, not the subclass: make checkout require a signed-in user, so a guest cannot even write the call.
Why it compiles: an override may take fewer parameters, and a thrown error is not part of the type. The promise "checkout creates an order" lives only in the callers' heads.
The symptom: if (cart instanceof GuestCart) checks spread before every checkout, which also breaks open/closed: each new kind of cart edits all of them.
The fix: the cart only holds items, and checkout(cart, user, api) needs a SignedInUser. Code without a user handles the guest case once, where it is known.
Other LSP breaks: a "not supported" throw, stricter input checks than the parent, null where the parent never returned it, a method that silently does nothing.
The Module pattern keeps state private in a closure and exposes a public API; Singleton ensures one instance with one global access point. An ES module does both: its top-level scope is private and it is evaluated once, so every file that imports it shares the same exported object.
The old Module pattern: an IIFE returning an object, as libraries were written before ES modules.
The class Singleton (private constructor, static instance) works in TypeScript but adds nothing over export const analytics = ....
Lazy creation: dbPromise ??= ... creates the object on first use, for things that are expensive, async or browser-only, such as an IndexedDB connection.
"Once" has limits: a module runs once per resolved URL in one realm; two copies of a package in node_modules, an iframe or a Web Worker each get their own "singleton".
The costs: the dependency appears in no signature, state carries over between tests in one file, and on a server one module-level object is shared by every request.
The usual fix: create the one instance in the app's entry point and pass it in or provide it through context; tests create their own.
A factory is a function or method that creates objects and returns them as an interface, so the caller never names a concrete class. createApiClient(env) returns the production client, a staging one or an in-memory fake for tests, and only the factory knows which.
One place decides: the switch on the environment lives in the factory; components and hooks depend only on ApiClient.
**TypeScript checks the switch**: with Env as a union and a declared return type, a missing case fails with "Function lacks ending return statement".
Factory Method, the GoF version, is a method a subclass overrides to choose the object the base class works with; frontend code usually passes a function instead.
Abstract Factory creates a family of objects that must match: createPlatform('desktop') returns the Electron storage and notifications, createPlatform('web') the browser ones, so they never get mixed.
In the platform: document.createElement('input'), axios.create(config), createRoot and createContext are factories.
An adapter converts one interface into the one your code expects, such as a backend DTO or an SDK into your own types. A facade puts one simple interface in front of several parts, such as users.search() over the URL, auth, JSON and errors. One changes a shape; the other hides complexity.
Adapter at the edge: toUser(dto) maps user_id, full_name and an ISO string to id, name and a Date once; when the backend renames a field, one function changes instead of every component.
Adapter around an SDK: wrap Amplitude or a maps SDK in your own Analytics interface with the methods you use, and switching vendors or faking it touches one file.
Facade: components call users.search('ada'); the query string, the token, the status check, JSON and mapping stay behind it. Here the facade uses the adapter, a common pairing.
Signs they leak: components still import the SDK's types or check res.status, or the facade mirrors every option of what it wraps and becomes a pass-through.
A proxy is a stand-in with the same interface as the real object that controls access to it: it creates the object lazily, caches results, checks permissions or forwards calls. The pattern is usually a small wrapper class; the JavaScript Proxy is one generic way to build one.
Kinds: virtual (create on first use), caching, protection (refuse calls the user may not make), remote (forward to a worker or a server).
Proxy vs decorator: the same structure, a different intent: a decorator adds behaviour around each call, a proxy decides when and whether the real object is reached.
Where you meet it: React.lazy is a virtual proxy for a component, a Service Worker is a network proxy in front of fetch, and Comlink's wrap(worker) makes a Web Worker look like a local object.
**A class is usually better than Proxy**: it is typed, easy to debug and adds no cost to a property read, while a Proxy runs a trap on every access and breaks #private fields and === identity.
**Proxy fits** when the methods cannot be listed in advance: remote calls (Comlink, the tRPC client), reactivity (Vue, MobX), Immer drafts.
A decorator wraps an object or function in another with the same interface and adds behaviour around the call: logging, timing, caching, retries. In TypeScript it is usually a higher-order function; the @decorator syntax (TypeScript 5.0+, from the TC39 proposal) does the same for class members.
The same interface is the point: withTiming('loadUser', fn) keeps the signature of fn, so callers do not change, and wrappers stack: withLogging(withRetry(fetchJson)).
TC39 decorators: a method decorator receives the method and a ClassMethodDecoratorContext (name, kind, addInitializer) and returns the replacement. For targets below esnext, TypeScript compiles @logged into plain function calls.
Two incompatible kinds: NestJS and TypeORM use the older experimentalDecorators form (target, key, descriptor), with parameter decorators and metadata that the TC39 form lacks.
The React form is the higher-order component: withAuth(Page) returns a component with the same props plus a check.
With Observer, the subject keeps its own list of observers and calls them, so each observer subscribes to a specific object. With publish/subscribe, a broker or channel sits in between: publishers and subscribers know only a topic or channel name, never each other.
Observer in the platform: addEventListener on an element, IntersectionObserver and ResizeObserver (named after the pattern), a Redux or Zustand store's subscribe, an RxJS Observable. React reads any of them with useSyncExternalStore.
Pub/sub in the platform: BroadcastChannel between tabs, postMessage between frames, WebSocket topics, an app-wide event bus; the publisher does not know whether anyone listens.
The trade-off: pub/sub decouples more, which you want across tabs and micro-frontends; inside one app it hides the flow: nobody can find who reacts to cart:updated, and a mistyped topic fails silently.
Every subscription needs an exit: subscribe returns an unsubscribe function or a Subscription; listeners that are never removed are the most common bug with this pattern.
bind creates a new function on every call, so off receives a function that was never registered and removes nothing. The store keeps calling the render of every panel ever opened, and its listener list keeps those panels and their removed elements in memory.
Listeners are matched by identity: EventTarget, Node's emitter and most stores remove the listener === the one you pass, and this.render.bind(this) !== this.render.bind(this). removeEventListener with a fresh arrow fails the same way.
The symptom grows: open and close the panel three times, and one store change runs three renders into detached elements.
Fix 1: register a function with a stable identity, an arrow class field bound once per instance, and pass the same reference to off.
Fix 2, more robust: have on return an unsubscribe function and keep it, so there is nothing to match. For DOM listeners, pass { signal } from an AbortController and call abort().
In React the unsubscribe is the effect's cleanup (return store.subscribe(...)), and useSyncExternalStore does it for you.
A mediator coordinates a group of objects that would otherwise call each other: each one reports its changes to the mediator, and the mediator decides who else must change. Observer only broadcasts that something happened; a mediator holds the rules of how the parts interact.
Why: five fields that affect each other need up to 20 direct links; with a mediator each knows one object, and the rules (no express delivery abroad, a VAT id only for companies) sit in one place, testable without UI.
In React the mediator is usually the common parent that lifts state up, or a reducer: children get values, report changes through callbacks and never call each other.
Combined with Observer: the parts often notify the mediator through events, and it decides what follows. In pub/sub each subscriber decides its own reaction; a mediator centralizes the coordination.
The risk: an app-wide mediator becomes a god object. Keep one per flow: a form, a page, a wizard.
Strategy makes an algorithm interchangeable: the caller passes in how to price, sort or validate. Command turns a request into a value, so it can be queued, logged, retried or undone. With first-class functions, a strategy is a function, and a command a function or a plain object.
Strategies you already use: the comparator in array.sort(compare), TanStack Query's retry and retryDelay options, a map of pricing or shipping rules.
**Why it beats a switch**: a new delivery option is one new entry, not an edit in every function that branches on the type; satisfies Record<string, ShippingStrategy> types each entry, and keyof typeof shipping derives the valid keys.
Commands you already use: an editor's command registry behind the command palette and shortcuts (VS Code, Tiptap's editor.chain().focus().toggleBold().run()), an offline outbox replayed when the connection returns.
Data or behaviour: a command kept as plain data can be saved to IndexedDB and replayed after a reload; one with an execute() method cannot, but can carry an undo() beside it.
Many patterns shrink to a language feature: Strategy and Command become functions, Singleton and Module an ES module, Iterator the iteration protocol. Adapter, Facade, Proxy and Mediator still need design, because they decide how parts connect.
Functions: Strategy, Command and Template Method reduce to passing a callback: sort(compare), retry(fn, { shouldRetry }), a renderItem prop.
Built in: Iterator is [Symbol.iterator] with generators and for...of; Observer is EventTarget.
Prototype: Object.create(proto) and structuredClone are the pattern, and the language's own inheritance is prototype-based.
Builder: an options object with defaults replaces most builders; fluent ones survive where the chain reads well or the type grows step by step: Kysely queries, Zod's z.string().min(3).
Chain of Responsibility and Decorator: an array of middleware, as in Redux (store => next => action), axios interceptors and Hono, plus higher-order functions.
Let the business code own the interface it needs (a port such as OrdersGateway, in its own types), and write the HTTP, GraphQL or in-memory code as adapters that implement it. The domain then never imports fetch, axios or the backend's DTOs, and the entry point picks the adapter.
Inversion is about import direction
Without it, orders/cancelOrder.ts imports api/httpOrders.ts. With it, orders/model.ts declares the port and api/httpOrders.ts imports it: the import points from the detail to the policy, against the calls.
Injection is only how the instance arrives (a parameter, a factory argument, React context); injecting a concrete HttpOrdersApi is injection without inversion.
What goes where
Port: domain types and errors (Order with totalCents and a Date, OrderNotFound); no Response, status codes or snake_case.
Adapter: URLs, headers, DTO mapping, and a 404 turned into OrderNotFound.
Composition root: main.ts or the root providers create createHttpOrders(baseUrl); tests and Storybook pass an in-memory one.
When it is not worth it
A CRUD screen that shows what the API returns gains only a layer: TanStack Query plus a client generated from OpenAPI is simpler. Invert where there is logic to protect (checkout, pricing, offline sync) or several data sources.
The port does not replace the query cache: queryFn calls the gateway, and caching and refetching stay in the library.
The usual three: a god object every feature reads and writes, a utils folder with no single purpose, and a shared component bent by flags to fit cases it was not made for. Refactor in small, tested steps, one responsibility at a time, keeping the old entry point until callers move.
God store or service
Signs: a 2,000-line appStore or ApiService that every feature imports, with merge conflicts in it on every PR.
Way out: split it by domain (cart, session), move server data into a query cache (often half of such a store), and let the old module re-export the new pieces while callers migrate; then delete it.
The utils dumping ground
Signs: utils.ts holds formatDate, isAdmin and calculateTax; everything imports it, so import cycles form through it.
Way out: business rules go back to their feature (checkout/tax.ts), generic code to shared/lib with one file per subject, and hand-written helpers give way to Intl.NumberFormat, structuredClone and Object.groupBy.
The wrong abstraction
Signs: a shared <DataTable> with 30 props and if (variant === 'legacy') inside that nobody dares to touch.
Way out: inline it back into its callers, let the copies differ, then extract what is really shared.
Making it stick
Cover the behaviour with tests first, refactor alongside feature work, and add a no-restricted-imports rule for the old module so it does not come back.