TypeScript Classes and Inheritance (2026) - TypeScript tutorial cover with bold white title lettering on a dark navy grid background.

Quick answer: TypeScript classes add visibility modifiers, typed fields and parameter properties to plain JavaScript classes. Use private and readonly to express intent, implements to guarantee a shape, and abstract for a base class that is never instantiated itself. Prefer composition over deep inheritance chains.The sections below follow a class from its typed fields through parameter properties, inheritance and interfaces, and end with composition, which is usually the better answer when TypeScript classes start to grow.

Quick answer card that answers 'typescript classes' in plain language for beginners.
typescript classes guide roadmap listing the 6 sections in reading order.

Your first typed TypeScript class

class Product {
  readonly id: number;
  name: string;
  private stock = 0;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  restock(units: number): void {
    if (units <= 0) throw new RangeError("units must be positive");
    this.stock += units;
  }

  get available(): boolean {
    return this.stock > 0;
  }
}
  • readonly — assignable only in the constructor, so id cannot change later.
  • private — visible only inside the class; this is a compile-time check, not a runtime guarantee.
  • get — a computed property read like a field, so callers write product.available.

Parameter properties: less boilerplate

When a constructor parameter is marked with a modifier, TypeScript declares and assigns the field for you. The class above shortens to this:

class Product {
  private stock = 0;

  constructor(
    readonly id: number,
    public name: string,
  ) {}

  restock(units: number): void {
    this.stock += units;
  }
}

Inheritance and abstract classes

abstract class Shape {
  constructor(public readonly name: string) {}
  abstract area(): number;              // subclasses must implement this

  describe(): string {
    return `${this.name} has area ${this.area()}`;
  }
}

class Circle extends Shape {
  constructor(public radius: number) {
    super("circle");
  }
  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

// new Shape("x");       // error — abstract classes cannot be instantiated

Call super() before touching this in a subclass constructor, and mark overridden methods with override so a typo in the parent method name becomes an error rather than a new method.

typescript classes concept map connecting the related topics this guide covers.

Interfaces versus TypeScript classes

An interface describes a shape with no implementation; a class provides implementation. implements makes a class promise to satisfy an interface, which is useful when several classes should be interchangeable.

interface Describable {
  describe(): string;
}

class Tag implements Describable {
  constructor(private label: string) {}
  describe(): string {
    return `#${this.label}`;
  }
}

function printAll(items: Describable[]) {
  items.forEach(i => console.log(i.describe()));
}

Because printAll depends only on Describable, not on a concrete class, you can pass anything that fits — a real class, a plain object literal, or a test double. That is the practical payoff of programming to interfaces.

Composition beats deep inheritance

Inheritance is good for a genuine is-a relationship — a Circle is a Shape. It becomes fragile when chains grow three or four levels deep, because a change at the top ripples everywhere. For has-a relationships, compose instead.

class Logger {
  log(msg: string) { console.log(`[${new Date().toISOString()}] ${msg}`); }
}

class UserService {
  private logger = new Logger();        // has a logger, does not extend one
  create(name: string) {
    this.logger.log(`Created ${name}`);
  }
}

Next: generics, where types become parameters. To learn TypeScript properly with a mentor, Ampersand Academy teaches web development one-to-one.

TypeScript classes: when to use them and when not to

Reach for a TypeScript class only when the thing has behaviour and identity. A TypeScript class earns its place when an object carries state that changes through its own methods — a connection that opens and closes, a queue that grows and drains, a cart that accepts and removes items.

When all you have is a bag of fields that get read and passed around, a class adds ceremony without adding safety: you write constructors and getters, and the type still ends up being satisfied by any object with the same fields. The practical test for TypeScript classes is simple — if you never call a method that mutates internal state, use an interface and a plain object.

Prefer composition when two candidates both look like classes. Deep inheritance chains in TypeScript classes couple every level to the one above it, so a change in a base class ripples through every subclass, and TypeScript makes the coupling visible earlier than JavaScript would because each override must remain assignable to the parent signature. Composition keeps that coupling in the constructor, where it can be read, replaced with a test double, or swapped for a different implementation entirely. Most designs that start as an inheritance tree turn out to be one useful object delegating to three smaller ones, and rewriting TypeScript classes that way usually shrinks the code rather than growing it.

Visibility modifiers are documentation with teeth, and they are what make TypeScript classes self-describing. Marking a field private states that callers must not touch it, and the compiler enforces the statement inside the file; marking it readonly states that the reference never changes after construction, which lets readers stop thinking about it.

Neither is a runtime guarantee — private compiles to ordinary properties — so they express intent to humans and rules to the compiler, and that is exactly the contract a TypeScript class needs. Where a member of TypeScript classes must be visible to subclasses but not to callers, protected expresses that middle state. Keep fields as private as the design allows, and the public surface of the class stays something you can change without breaking consumers.

Avoid using TypeScript classes as namespaces for free functions. If several functions never touch shared mutable state, a TypeScript class with only static methods is a module with extra punctuation: it cannot be mocked through an interface, it invites global singletons, and it hides the import graph behind a familiar-looking object. JavaScript modules already give you namespacing, and a typed function exported from a plain file is easier to test and to tree shake. The same advice covers the singleton service pattern: when the value is genuinely application-wide state, hold it in a module or inject it, rather than burying construction inside the class so that no test can substitute a different instance.

Generics are where TypeScript classes quietly shine, and where plain objects start to strain. A wrapper that must remember the type it holds — a result container, a cache keyed by an id type, a builder producing one output shape — benefits from a generic class because the type parameter travels with each instance instead of being re-asserted at every call site.

Keep the parameter meaningful and single-letter-free where clarity helps, and constrain it only when the class truly depends on some capability. Used this way, TypeScript classes become small, reusable contracts: the state lives behind methods, the type is preserved across calls, and the alternative — a loose object plus helper functions that must agree by convention — loses every one of those guarantees.

The decision has a maintenance cost either way, so make it deliberately at the start of a feature. TypeScript classes pay back when behaviour and state evolve together over months, and they punish you when the feature turns out to be a data transfer shape after all. Interfaces pay back when the shape is stable and interchangeable, and they punish you when logic accretes around the data and spreads into free functions with hidden dependencies. Review the boundary periodically: a plain object with three functions attached is the signal that a TypeScript class was the right call after all, just later than you guessed. Design for the code you are writing now, with enough seams to change your mind cheaply.

typescript classes guide coverage grid showing every section covered in this article.

Runtime privacy in TypeScript classes

Modifiers like private provide type-level enforcement during compilation. When compiler output targets JavaScript, visibility keywords disappear entirely. Anyone consuming the compiled bundle can read or mutate private fields through bracket access or generic property lookups. For modern projects, TypeScript classes can instead leverage ECMAScript private fields prefixed with a hash mark. Native private fields enforce encapsulation directly inside the runtime engine, making unauthorized external reads impossible even through reflection.

Hard privacy with hash names

Hard private fields solve data leaks in security-sensitive layers like authentication tokens or cryptographic keys. Subclasses cannot read hard private fields from parent instances, preventing accidental state contamination across inheritance boundaries. Consider how native private fields behave alongside standard access modifiers:

class Vault { #secretKey: string; private label: string;constructor(label: string, secretKey: string) { this.label = label; this.#secretKey = secretKey; }unlock(candidate: string): boolean { return this.#secretKey === candidate; } }const safe = new Vault("Primary", "token-994"); // (safe as any).label returns "Primary" // (safe as any).#secretKey causes a runtime syntax error

Choosing between accessibility mechanisms requires evaluating serialization needs.

Built-in mechanisms like JSON.stringify omit hash-prefixed private fields completely during serialization. Conversely, soft private fields convert into standard enumerable properties in emitted output unless configured with custom getters. When designing data-centric models, developers often prefer soft modifiers, reserving hash syntax for internal state machines, database handles, and sensitive background workers inside TypeScript classes.

Memory behavior also differs between these mechanisms. Modern virtual machines optimize traditional object shapes when fields remain predictable. Hard private properties rely on hidden internal key maps, which introduce slight memory overhead when applications instantiate millions of short-lived objects. Evaluate your security profile before choosing which privacy mechanism to apply across your domain models.

Arrow methods and lexical scope pitfalls

Execution context errors represent one of the most frequent hurdles when passing methods into event handlers or callback pipelines. When a standard method detaches from an instance, its this reference defaults to undefined under strict mode. Developers frequently turn to arrow functions declared as instance properties to permanently bind the scope to the originating instance. However, this convenience introduces subtle performance trade-offs across large collections of TypeScript classes.

Standard methods live on the class prototype, meaning a thousand instances share exactly one function reference in memory. In contrast, arrow function properties initialize inside the constructor for each separate instantiation. Creating ten thousand instances with four arrow methods produces forty thousand distinct function closures in heap memory. In performance-critical applications, profiling memory footprints reveals significant overhead when developers overuse arrow fields across high-churn TypeScript classes.

class RequestQueue {
  private pending: string[] = [];

  // Prototype method: single memory allocation
  flush(): void {
    this.pending = [];
  }

  // Bound arrow property: allocated per instance
  boundFlush = (): void => {
    this.pending = [];
  };
}

const queue = new RequestQueue();
const trigger = queue.boundFlush;
trigger(); // Safely executes with correct this context

class RequestQueue { private pending: string[] = [];// Prototype method: single memory allocation flush(): void { this.pending = []; }// Bound arrow property: allocated per instance boundFlush = (): void => { this.pending = []; }; }const queue = new RequestQueue(); const trigger = queue.boundFlush; trigger(); // Safely executes with correct this context

Inheritance introduces another complication with arrow properties. Because derived initializers execute in a specific order, overriding an arrow property in a derived definition can overwrite instance state prematurely. Furthermore, calling super.method() fails with arrow properties because the arrow function lives on the instance, not on the prototype chain where super lookups occur.

When deciding how to manage execution scope, adopt clear team conventions:

  • Use standard prototype methods by default to minimize memory allocations and maintain expected inheritance behaviors.
  • Rely on arrow function properties strictly at application boundaries, such as user interface component callbacks or message dispatchers.
  • Explicitly bind methods inside the constructor when you need the prototype chain preserved while passing handlers to event loops.

Static initialization blocks and inheritance

Configuring static state historically forced awkward patterns like immediately invoked functions or external setup routines. Modern language standards include static initialization blocks, allowing multi-statement execution, error handling, and environment reading during initial module evaluation. These blocks execute exactly once when the JavaScript engine loads the declaration. Within complex domain systems, static initialization blocks allow TypeScript classes to configure shared registries while retaining full access to private static members.

Static blocks shine when preparing database drivers, reading configuration variables, or populating lookup tables that require defensive validation before any instance exists. Because static blocks execute in sequence with property initializers, you can place try-catch statements directly around critical bootstrapping routines. If an external configuration fails validation, the static block halts application startup immediately with an actionable error trace.

class DatabaseConnection {
  static #poolSize: number;
  static defaultTimeout = 5000;

  static {
    try {
      const envSize = Number(process.env.DB_POOL_SIZE);
      DatabaseConnection.#poolSize = Number.isInteger(envSize) ? envSize : 10;
    } catch {
      DatabaseConnection.#poolSize = 10;
    }
  }

  static get poolCapacity(): number {
    return DatabaseConnection.#poolSize;
  }
}

class DatabaseConnection { static #poolSize: number; static defaultTimeout = 5000;static { try { const envSize = Number(process.env.DB_POOL_SIZE); DatabaseConnection.#poolSize = Number.isInteger(envSize) ? envSize : 10; } catch { DatabaseConnection.#poolSize = 10; } }static get poolCapacity(): number { return DatabaseConnection.#poolSize; } }

Inheritance introduces strict execution order rules that developers must anticipate. Base static properties initialize first, followed by base static blocks, before child declarations execute. Subclasses inherit static members through prototype linkage, meaning reading a property on a child accesses parent memory unless shadowed. Consider the following workflow when organizing static state across specialized TypeScript classes:

A frequent pitfall involves mutating inherited static state. When code reassigns an inherited static property on a derived constructor, JavaScript creates an own property on that derived constructor rather than modifying the parent value. Conversely, mutating an internal array or map held on an inherited static field alters shared data across every sibling subclass. Teams must treat inherited static state as strictly immutable to avoid unexpected cross-tenant data bleed.

Building mixins with TypeScript classes

Single inheritance prevents an object from extending more than one parent directly. When disparate parts of a domain require shared capabilities, such as event emissions, audit logging, or disposable resource cleanup, deep hierarchies break down. Mixins offer a composable alternative by expressing reusable behaviors as factory functions. These factories accept a base constructor, dynamically extend it, and return an augmented definition, providing horizontal code reuse across otherwise unrelated TypeScript classes.

Composing behaviors without extends

Typing mixins requires defining a generic constructor type that models the instantiation signature of the base target. By using the spread parameter pattern for constructor arguments, mixin wrappers guarantee that subclasses preserve the constructor requirements of whatever base class they wrap. This eliminates the rigid coupling common in deep inheritance trees, allowing developers to stack orthogonal behaviors cleanly.

Review the workflow below to see how mixins compose capabilities into domain entities without duplicating boilerplate logic:

  1. Declare a constructor type alias representing any constructible type capable of receiving arbitrary arguments.
  2. Write a factory function that accepts that constructor and returns an inner subclass adding specific fields and methods.
  3. Pass the base class through multiple mixin functions sequentially to assemble the target composite entity.

By composing targeted capabilities through mixins rather than monolithic inheritance trees, systems remain modular and simple to refactor. If an operational requirement changes, developers can add or extract individual behavioral mixins without modifying the inheritance hierarchy of core TypeScript classes.

Static type checkers trace mixin return types accurately, enabling autocomplete and type inference across all stacked methods. While mixins introduce extra function call overhead during compilation, the resulting runtime code remains straightforward prototype manipulation. Use mixins when multiple distinct subsystems require identical operational mechanics but cannot share a common semantic ancestor.

Strict property initialization patterns

The strictPropertyInitialization compiler check protects codebases from runtime null pointer exceptions. When enabled, the compiler verifies that every declared property receives an assignment either directly inline or within the constructor body. However, modern enterprise architectures often rely on external dependency injection, lifecycle hooks, or asynchronous setup routines. Navigating these requirements demands a clear understanding of property initialization rules across modern TypeScript classes.

Developers often bypass compiler warnings by appending the definite assignment assertion operator to property names. Writing name!: string silences the compiler by asserting that a property will receive its value prior to runtime execution. While occasionally necessary when integrating legacy frameworks or database entity decorators, this assertion turns off compile-time safety nets. If an asynchronous lifecycle step fails or executes out of sequence, consuming methods encounter undefined values without prior warning.

Rather than relying on non-null assertions, robust applications structure asynchronous initialization through static factory methods. Static factories manage asynchronous resource acquisition before invoking the private constructor, ensuring every field contains a valid value upon return. This design pattern prevents partially constructed states from leaking into consumer pipelines, ensuring dependable lifecycle guarantees across production TypeScript classes.

Adhering to these property initialization guidelines yields reliable results:

  • Enable strict property checking in compiler settings to catch uninitialized fields at compile time.
  • Prefer static factory functions over asynchronous init methods to guarantee fully initialized instances upon construction.
  • Restrict definite assignment assertions strictly to properties populated by vetted object-relational mapping libraries.
  • Provide fallback default values directly inline whenever fields represent optional operational configuration.

Adopting factory methods also simplifies test suites. Unit tests can supply mocked dependencies directly to synchronous constructors, while production pipelines consume static builders that wire live networks. This separation eliminates race conditions caused by methods executing against half-initialized instances, preserving strong integrity contracts throughout the application lifetime.

Frequently asked questions

Are TypeScript private fields actually private at runtime?

No. The private modifier is a compile-time check only. For real runtime privacy, use a JavaScript hash-private field written with a leading hash, which the engine enforces.

What is an abstract class in TypeScript?

A class that cannot be instantiated directly and may declare abstract members that subclasses must implement. It is the base for a family of related classes.

What is the difference between an interface and a class?

An interface describes a shape with no implementation, while a class provides the implementation. A class can implement an interface to guarantee it satisfies that shape.

What are parameter properties in TypeScript?

A shorthand where adding public, private, protected or readonly to a constructor parameter declares and assigns a field automatically, removing the repeated this.x equals x lines.

Should I use inheritance or composition?

Use inheritance for a genuine is-a relationship such as Circle is a Shape. Use composition for has-a relationships, because deep inheritance chains are fragile to change.

Last updated on · Written by