164: Classes, Constructors, Access Modifiers, Abstract Classes, and Composition
Learning outcomes
By the end of this lesson, you can:
- explain and apply instance and static sides in a realistic implementation;
- explain and apply constructors and parameter properties in a realistic implementation;
- explain and apply public private protected in a realistic implementation;
- explain and apply abstract classes in a realistic implementation;
- explain and apply inheritance and polymorphism in a realistic implementation.
These outcomes are deliberately implementation-focused. The goal is not to recite what a class feature is, but to choose a design, explain the contract it creates, and recognize where that contract stops protecting you at runtime.
Prerequisites and retrieval
This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before reading, retrieve one concrete example from a previous project where the same concern appeared. Perhaps you used a class to hold state, a static factory to create an object, or a small set of functions that would have been easier to test than a deep inheritance tree. Use that example as a comparison point.
The purpose of the retrieval is not to memorize terminology. It is to make a defensible decision inside a strict TypeScript codebase. The compiler can help model domain invariants, but static types do not replace runtime validation. Values arriving from a request, file, database, or JavaScript caller are still untrusted until code checks them.
Terminology
- Instance and static sides: Instance members belong to created objects, while static members belong to the class constructor itself. The two sides have different types and different uses.
- Constructors and parameter properties: A constructor establishes the initial state of an instance. Constructor parameter properties can declare and initialize members concisely.
- public private protected: TypeScript access modifiers are compile-time contracts that describe which code may access a member. They do not all provide the same runtime privacy.
- Abstract classes: An abstract class can provide shared implementation and require subclasses to implement abstract members. It cannot be instantiated directly.
- Inheritance and polymorphism: Inheritance relates a subtype to a base contract. Polymorphism lets code work through that base contract while the concrete subtype supplies the behavior. Subtypes must remain substitutable for their base contracts.
- Composition: Composition assembles small collaborating objects or functions instead of making one type inherit a large, fragile hierarchy. It is often the simpler way to vary behavior.
Mental model
Treat Classes, Constructors, Access Modifiers, Abstract Classes, and Composition as a design problem with observable inputs, outputs, invariants, and failure modes. A class is not automatically a better design than a function or an object literal. It is useful when identity, state, lifecycle, or a cohesive set of operations needs to be represented and protected by a clear contract.
TypeScript classes sit on JavaScript's prototype and runtime class model while adding compile-time visibility and abstract contracts. That split is the source of several common misunderstandings: a private member can be inaccessible to TypeScript callers without being a secret at runtime, and an abstract requirement does not make JavaScript perform a runtime check for every external value. Learners should know both when a class is useful and when plain composition is simpler.
A strong implementation makes assumptions visible, narrows uncertainty at boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to prove why the design is safe. A type annotation tells the compiler what a value is expected to be; it does not prove that a network payload actually has that shape.
A useful interview and production sequence is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
Do not jump from a requirement directly to a library call or an inheritance relationship. First state what must remain true. Then choose the mechanism that enforces it, and decide how you will observe a violation when that mechanism is insufficient.
Deep dive
1. Instance and static sides
The problem usually appears when a developer expects a class to have one single type. It has two related sides instead. Instance members belong to objects created from the class, so they can use that object's state. Static members belong to the constructor value, so callers use them through the class name without creating an instance.
Generic parameters on a class describe the instance side. A Repository<User> instance can work with User, but the class constructor itself is not separately a Repository<User> for every possible User. Static members are shared by the constructor and therefore cannot use an instance type parameter as though every static call had one particular instance type. This distinction matters when designing static factories, registries, counters, and generic classes.
Decision rule: Use instance and static sides deliberately when it makes the contract or invariant easier to prove. If a static member only reduces typing while hiding which state it reads or which type it creates, prefer the more explicit design.
2. Constructors and parameter properties
A constructor is the boundary at which an instance receives its initial state. That makes it a good place to establish invariants that must hold for every valid instance, such as requiring a non-empty identifier or storing a dependency. Constructor parameter properties can declare and initialize those members concisely, for example with constructor(private readonly clock: Clock). The syntax is convenient, but the resulting member is still an ordinary class member with the selected visibility and mutability.
Do not let convenience turn constructors into hidden I/O workflows that are difficult to test. A constructor that opens a connection, reads configuration, or performs a network request makes object creation unpredictable and complicates failure handling. Prefer injecting already-created capabilities and keeping asynchronous work in an explicit method or factory. Also remember that a parameter property does not validate its argument; a string received from outside the trusted part of the program is still only checked if runtime code checks it.
Decision rule: Use constructors and parameter properties deliberately when they make the initial contract or invariant easier to prove. If they only reduce typing while hiding an assumption or side effect, prefer an explicit factory, dependency injection, or initialization method.
3. public private protected
The first design question is not which keyword looks most restrictive. It is which operations should be part of the public contract. public members are available to callers, private members are available only within the declaring class, and protected members are available within the declaring class and its subclasses. TypeScript access modifiers are compile-time contracts, so they guide valid source code and refactoring but are not a complete security boundary.
There is a further distinction at runtime. ECMAScript #private fields provide runtime private semantics: code outside the class cannot read them through ordinary property access, and they have different syntax and compatibility behavior. TypeScript's private keyword may be erased during compilation, depending on the target and emitted code. Neither form should be used as a substitute for authorization or for validating data supplied by an attacker.
Decision rule: Use public private protected deliberately when the visibility contract makes the invariant easier to prove. Expose the smallest useful public surface, use private for implementation details, and choose protected only when subclasses genuinely need a stable extension point. If subclasses need frequent access to internals, composition may provide a clearer boundary.
4. Abstract classes
An abstract class is useful when several implementations share meaningful state or behavior but must provide different details. The base class can implement the common workflow and declare an abstract member for the part that varies. A caller can depend on the base contract, while concrete subclasses supply the required operation. The abstract class itself cannot be instantiated directly.
Prefer this form when the shared implementation and lifecycle are real. An abstract class is not merely a more elaborate interface, and it creates a single inheritance relationship. If the only shared concern is a method shape, an interface is usually enough. If behavior needs to vary independently in several dimensions, composition avoids forcing those dimensions into one hierarchy.
Decision rule: Use abstract classes deliberately when shared state and behavior make the contract or invariant easier to prove. If the base class contains little more than declarations, or if subclasses must disable or work around its workflow, prefer an interface or composition.
5. Inheritance and polymorphism
Inheritance is useful when a subtype genuinely satisfies the base type's promises, not simply because it happens to share fields. Polymorphism lets a service call a method on the base contract without branching on every concrete type. That reduces conditional logic, but it also means every implementation must honor the expectations established by the contract.
Subtypes must remain substitutable for their base contracts. Overriding a method with stricter preconditions, returning a surprising result, weakening an established postcondition, or adding unexpected side effects violates the design even if the compiler accepts the shape. For example, code written for a base Notifier should not fail merely because one subtype cannot handle an ordinary message that the base contract permits.
Decision rule: Use inheritance and polymorphism deliberately when substitutability and shared behavior make the contract or invariant easier to prove. If the relationship is really “uses,” “contains,” or “can be configured with,” composition is usually a safer fit.
6. Composition
Composition builds behavior by connecting small collaborators. A notification service might receive a Sender capability, a clock, and a logger rather than inherit from a large base class. Each collaborator can have a narrow interface, and tests can supply a deterministic fake. This makes dependencies visible and allows one behavior to vary without changing an entire hierarchy.
Small collaborating objects and functions often avoid deep inheritance hierarchies. Inject capabilities such as storage or clock interfaces so domain behavior can be tested independently of frameworks. Composition does not remove design work: you still need to define the collaborator contracts, lifecycle, error behavior, and ownership of state.
Decision rule: Use composition deliberately when small, replaceable collaborators make the contract or invariant easier to prove. If composition merely scatters one cohesive invariant across unrelated objects, a class or abstract base may be clearer.
Worked example
Consider a strict TypeScript codebase where the compiler is used to model domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts, identify the invariant that must remain true, and identify which of the concepts above owns each failure mode.
The important move is separation. Parsing or validation belongs at the boundary; domain rules belong in the domain or service layer; persistence rules belong in the database or repository; presentation rules belong in the client. A class may help organize the domain behavior, but it does not make a malformed request valid merely because the request is passed to a typed constructor. Mixing these concerns can make a happy-path demo look shorter while making edge cases much harder to reason about.
For example, create a branded identifier only after checking the unknown input:
type TaskId = string & { readonly __brand: 'TaskId' };
function parseTaskId(value: unknown): TaskId {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError('Invalid task id');
}
return value as TaskId;
}
The brand is a compile-time technique for preventing an ordinary string from being passed where a validated TaskId is expected. The assertion does not perform the validation; the preceding runtime check does. A production boundary may need stronger rules—such as trimming, a format check, or a maximum length—depending on the domain. The example intentionally keeps the invariant small and visible.
Walk the example with at least four cases: the normal path, an empty or missing value, a duplicate/retry/concurrent path where relevant, and a dependency failure. For each case, state which layer detects the problem and what the caller observes. In this example, the parser owns the malformed or missing value. A duplicate task might belong to a repository constraint or domain rule, a concurrency conflict might belong to persistence, and a dependency failure should be represented by the service or repository contract rather than silently converted into success. This is the level of explanation expected in a senior code review or technical interview.
Production perspective
Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high cardinality. A class hierarchy can compile cleanly and still leak resources, make retries unsafe, or turn an operational error into an ambiguous result. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence.
When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and the network input is untrusted. Access modifiers and abstract methods organize application code; they do not authorize a user or validate a request at the system boundary.
Guided lab
Implement the same notification workflow twice: once with an abstract base class and once with composition over a Sender interface. Compare extension cost, testing, and where shared state lives. Keep the externally observable behavior equivalent so that the comparison is about design rather than two different requirements. Record which dependencies are created by each design and where failures are translated into caller-visible errors.
Complete the lab with this discipline:
- Write the requirement and two non-requirements.
- List input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note.
The comparison should include a concrete observation. For example, inspect whether a test must construct an entire base-class lifecycle to replace one sender, or whether the composition version can inject a small fake. Also check whether either implementation makes retry behavior, ordering, or duplicate delivery ambiguous.
Edge cases and failure modes
- Instance and static sides: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check both an instance call and a static call, and verify that a generic instance type has not been incorrectly assumed to be available on the static side.
- Constructors and parameter properties: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check that invalid external values are rejected and that construction does not unexpectedly perform I/O.
- public private protected: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Verify that the public API cannot accidentally mutate state, and do not treat a compile-time
privatemember as a security boundary. - Abstract classes: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Test the shared workflow and each required subclass behavior, including failures in the varying operation.
- Inheritance and polymorphism: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Pass every subtype through the base contract and look for stricter preconditions, surprising side effects, or results that break substitutability.
- Composition: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Replace each injected capability with a deterministic test double and verify who owns state and error translation.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. - Testing only the happy path and therefore discovering contracts only after integration.
- Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
- Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.
- Treating
private,protected, orabstractas runtime validation or security features when they primarily constrain TypeScript source code. - Using inheritance for code reuse even though the subtype cannot honor the base type's promises.
For debugging, reproduce the smallest failing case, inspect the actual value or execution plan, trace the boundary where the invariant first becomes false, and fix the owning layer rather than adding a downstream patch. If the issue concerns a class, inspect the emitted JavaScript and the object actually created; do not infer runtime behavior only from the TypeScript declaration. If it concerns a dependency, inspect its inputs, timeout, cancellation, and error path. Then add a focused test that would fail if the invariant regressed.
Interview questions
- What problem do instance and static sides solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do constructors and parameter properties solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do
public,private, andprotectedsolve, and what trade-off or failure mode would make you choose a different approach? - What problem do abstract classes solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do inheritance and polymorphism solve, and what trade-off or failure mode would make you choose a different approach?
Answer each question with more than a definition. Name the contract, give a small example, mention the compile-time or runtime boundary involved, and describe a case where composition or a plain function would be safer.
Checkpoint
Without notes, explain Classes, Constructors, Access Modifiers, Abstract Classes, and Composition to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.
As a self-check, make sure you can explain which parts of the example the compiler can verify and which parts require runtime checks or database constraints. If your alternative design changes the failure behavior, state that explicitly rather than presenting the two designs as interchangeable.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or stricter reliability.
