TypeScript Classes & Decorators: Syntax, Uses, and Pitfalls

TypeScript classes give you a structured way to define objects with fields, methods, inheritance, and access control. Decorators add a way to attach extra behavior or metadata to classes and class members, which is useful in frameworks, validation libraries, and tooling.

Quick answer: Use a class when you want reusable object templates with methods and inheritance. Use a decorator when you need to annotate a class, method, accessor, property, or parameter so another tool or runtime layer can inspect or modify it.

Difficulty: Intermediate

You'll understand this better if you know: basic JavaScript objects, functions, prototypes, and how TypeScript adds types to JavaScript syntax.

1. What Is TypeScript Classes & Decorators?

In TypeScript, a class is a typed version of the JavaScript class feature, with optional type annotations, access modifiers, and compile-time checks. Decorators are special functions written with the @ syntax that can run on a class or one of its members.

TypeScript classes are part of everyday JavaScript development. Decorators are more specialized and are commonly used by libraries that need to inspect or alter class behavior without changing the class body directly.

2. Why TypeScript Classes & Decorators Matter

Classes help you model real application concepts such as users, forms, services, and data records. They keep related data and behavior together, which makes code easier to organize and reuse.

Decorators matter because they let you apply cross-cutting behavior in a consistent way. Instead of repeating validation, logging, routing, or dependency metadata across many methods, you can mark the class or member once and let a library handle the rest.

They are especially helpful when your codebase needs:

3. Basic Syntax or Core Idea

A TypeScript class starts with the class keyword, followed by fields, a constructor, and methods. Decorators are written as @name above the class or member they apply to.

Simple class syntax

This example shows a basic class with typed fields and a method:

class User {
  name: string;
  isActive: boolean;

  constructor(name: string) {
    this.name = name;
    this.isActive = true;
  }

  greet(): string {
    return `Hello, ${this.name}!`;
  }
}

This class creates objects with a name, an active state, and a greeting method. TypeScript checks that name is a string and that greet() returns a string.

Simple decorator syntax

A decorator is a function that receives the thing being decorated. For a class decorator, the function receives the class constructor:

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed
class Product {
  id: number = 1;
}

The @sealed line tells TypeScript to apply the decorator to the class. In practice, decorators are often used by libraries rather than hand-written business logic.

4. Step-by-Step Examples

Example 1: A class with a constructor and method

Use classes when multiple objects share the same shape and behavior. Here, each instance stores a title and a page count:

class Book {
  title: string;
  pages: number;

  constructor(title: string, pages: number) {
    this.title = title;
    this.pages = pages;
  }

  summary(): string {
    return `${this.title} has ${this.pages} pages.`;
  }
}

const book = new Book("TypeScript Guide", 320);
console.log(book.summary());

This example shows the most common class pattern: store data in fields, initialize them in the constructor, and expose behavior through methods.

Example 2: Public, private, and readonly members

TypeScript lets you express intent with access modifiers. This helps prevent accidental misuse of internal state:

class BankAccount {
  public owner: string;
  private balance: number;
  readonly accountId: string;

  constructor(owner: string, accountId: string) {
    this.owner = owner;
    this.accountId = accountId;
    this.balance = 0;
  }

  deposit(amount: number) {
    this.balance += amount;
  }

  getBalance(): number {
    return this.balance;
  }
}

Outside code can read owner and accountId, but it cannot access balance directly. That keeps updates routed through controlled methods.

Example 3: Extending a base class

Inheritance lets a child class reuse base behavior and add its own details:

class Animal {
  move(distance: number): string {
    return `Moved ${distance} meters.`;
  }
}

class Dog extends Animal {
  bark(): string {
    return "Woof!";
  }
}

const dog = new Dog();
console.log(dog.move(5));
console.log(dog.bark());

The child class gets move() from Animal and adds bark(). This is the standard way to share behavior across related classes.

Example 4: A method decorator that logs calls

Decorators can wrap or observe methods. This example logs the method name and arguments each time the method runs:

function logMethod(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: unknown[]) {
    console.log(`Calling ${propertyKey} with:`, args);
    return original.apply(this, args);
  };
}

class Calculator {
  @logMethod
  add(a: number, b: number): number {
    return a + b;
  }
}

This is a common decorator use case: add behavior around a method without changing the method body.

5. Practical Use Cases

Classes are most useful when an object has both data and behavior. Decorators are most useful when another system needs to inspect or modify that class structure in a predictable way.

6. Common Mistakes

Mistake 1: Expecting decorators to work without enabling support

Decorators are not enabled by default in every TypeScript project. If the compiler is not configured for them, the code will not compile.

Problem: The compiler may report that decorators are not allowed or that the syntax is unsupported when the project is missing the right TypeScript settings.

function sealed(constructor: Function) {
  // decorator body
}

@sealed
class Example {}

Fix: Enable decorator support in your TypeScript configuration when your toolchain expects legacy decorators.

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

Once the compiler understands decorators, the syntax can be parsed and emitted correctly for that setup.

Mistake 2: Trying to use private class fields as if they were TypeScript-only

TypeScript access modifiers like private are checked at compile time, but ECMAScript private fields use a different runtime syntax. Mixing the two can be confusing.

Problem: A value marked private in TypeScript is blocked by the type checker, but it is not the same thing as a runtime-private field. Developers sometimes expect stronger runtime protection than they actually get.

class Session {
  private token: string = "abc123";
}

const session = new Session();
console.log(session.token);

Fix: Keep the member private through TypeScript and expose controlled access through methods or getters.

class Session {
  private token: string = "abc123";

  getToken(): string {
    return this.token;
  }
}

The corrected version keeps access intentional, which makes the class easier to maintain and safer to use.

Mistake 3: Writing a decorator that changes behavior but forgets to return the right thing

Method decorators often wrap the original method. If the wrapper does not preserve the original call signature or return value, the class can behave unpredictably.

Problem: A decorator can silently break a method by replacing it with a function that does not forward arguments or return the original result.

function brokenLog(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  descriptor.value = function () {
    console.log(`Called ${propertyKey}`);
  };
}

Fix: Forward the arguments and return the original result when wrapping the method.

function safeLog(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: unknown[]) {
    console.log(`Called ${propertyKey}`);
    return original.apply(this, args);
  };
}

The fixed version preserves the method contract, which is the main rule for reliable decorators.

7. Best Practices

Practice 1: Keep classes focused on one responsibility

A class should usually represent one clear concept. If a class starts handling data access, formatting, validation, and UI rules all at once, it becomes harder to test and decorate safely.

class InvoiceCalculator {
  calculateTotal(subtotal: number, taxRate: number): number {
    return subtotal + subtotal * taxRate;
  }
}

Focused classes are easier to extend and easier for decorators or tests to target precisely.

Practice 2: Prefer methods or getters over direct field exposure

Even when a value is simple today, a getter gives you room to change implementation later without breaking callers.

class Profile {
  private firstName: string;
  private lastName: string;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }
}

This keeps the public API stable even if the internal fields change later.

Practice 3: Use decorators for cross-cutting concerns, not core logic

Decorators are strongest when they manage repeated concerns like logging, authorization, or metadata. They are a poor fit for business logic that should be obvious in the method body.

function logCall(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: unknown[]) {
    console.log(`${propertyKey} called`);
    return original.apply(this, args);
  };
}

This approach keeps your business rules readable while still adding reusable behavior around them.

8. Limitations and Edge Cases

A common “not working” situation is expecting a decorator to execute in every plain JavaScript runtime without compiler support. In TypeScript projects, always check the compiler version and decorator mode before relying on the syntax.

9. Practical Mini Project

Here is a small project-style example: a task tracker class with a decorator that logs updates. This shows how a class can hold state while a decorator adds a reusable behavior layer.

function trace(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: unknown[]) {
    console.log(`Updating via ${propertyKey}:`, args);
    return original.apply(this, args);
  };
}

class TaskList {
  private tasks: string[] = [];

  @trace
  addTask(task: string): void {
    this.tasks.push(task);
  }

  getTasks(): string[] {
    return [...this.tasks];
  }
}

const todo = new TaskList();
todo.addTask("Write docs");
todo.addTask("Review examples");
console.log(todo.getTasks());

This mini project shows a realistic pattern: the class owns the task data, while the decorator adds tracing without cluttering the business method.

10. Key Points

11. Practice Exercise

Expected output: Calling the method should log the method name and then print the formatted profile string.

Hint: Preserve the original method with descriptor.value and forward arguments with apply.

function logBefore(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: unknown[]) {
    console.log(`Calling ${propertyKey}`);
    return original.apply(this, args);
  };
}

class UserProfile {
  constructor(private name: string, private email: string) {}

  @logBefore
  display(): string {
    return `${this.name} <${this.email}>`;
  }
}

const profile = new UserProfile("Ava", "[email protected]");
console.log(profile.display());

12. Final Summary

TypeScript classes help you build structured, reusable object models with typed fields, methods, constructors, and inheritance. They are a natural fit whenever data and behavior belong together in one abstraction.

Decorators add a second layer of power by letting external logic attach to classes and members. That makes them useful for logging, validation, metadata, and framework integration, but they also add complexity and depend on compiler support and consistent tooling.

If you are learning both at once, start with class syntax, then practice inheritance, access modifiers, and constructors before moving on to decorators. Once those pieces feel comfortable, decorators become much easier to reason about and apply safely.