TypeScript Constructors
You're instantiating a new UserProfile class, but half your properties are undefined at runtime. The bug traces back to how you initialized the object. Constructors control exactly how your objects come to life, and getting them right prevents these frustrating initialization bugs.
This guide shows you practical constructor patterns in TypeScript, from basic initialization to advanced techniques like overloading, private constructors, and dependency injection. You'll learn when to use parameter properties versus traditional declarations, how to handle inheritance properly with super(), and when to reach for static factory methods instead of constructors.
Understanding TypeScript Constructors
A constructor in a TypeScript class is defined using the constructor keyword within a class. Here's a simple example:
class Person {
private name: string;
private age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
This constructor accepts two parameters, name and age, and assigns them to the corresponding class properties. When you create a new instance of the Person class using new Person("Alice", 30), the constructor runs automatically to set up the object.
Constructors serve as the entry point for object creation, giving you control over how objects are initialized. They can validate input data, set default values, and ensure your objects start in a consistent state.
Unlike regular methods, constructors don't have a return type since they always return an instance of the class. If you need to create factory patterns with different return types, consider using static methods instead.
When working with TypeScript in frameworks like Convex, constructors help establish clear initialization patterns for your classes, making your code more maintainable and type-safe.
Simplifying with Parameter Properties
Parameter properties allow you to declare and initialize class properties directly in the constructor parameters, reducing boilerplate code significantly.
class Person {
constructor(public name: string, private age: number) {}
}
In this example, the public and private modifiers before the parameters automatically create and initialize class properties with the same names. The code above is equivalent to:
class Person {
public name: string;
private age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
Parameter properties streamline class definitions by combining property declaration, parameter definition, and assignment in a single statement. They support all access modifiers: public, private, protected, and readonly.
This pattern is particularly useful when working with TypeScript interfaces, as it ensures your class implementation satisfies the interface contract with minimal code. When building full-stack apps, parameter properties help create clean, maintainable classes with less redundancy.
For complex classes with multiple properties, parameter properties make your code more readable by focusing on the essential structure rather than repetitive initialization logic.
Using super in Subclass Constructors
When creating a class that inherits from another class, you need to call the parent class's constructor before accessing this in the child class. This is where the super() keyword comes into play.
class Animal {
constructor(public name: string) {}
}
class Dog extends Animal {
constructor(name: string, public breed: string) {
super(name); // Calls the superclass constructor
}
}
In this example, the Dog class inherits from the Animal class using the extends keyword. The super(name) call in the constructor invokes the parent class constructor, ensuring that the parent's initialization logic runs before the child's.
This call to super() is mandatory in TypeScript when a class extends another class and has its own constructor. If you forget to call super(), TypeScript will throw an error because a derived constructor must call the parent constructor before it can use this.
The arguments passed to super() should match the parameters expected by the parent class constructor. When the parent class initializes properties through its constructor, you must provide these values through the super() call.
When working with TypeScript inheritance, the proper use of super() ensures that object initialization occurs in the correct order, following the inheritance chain from parent to child.
Managing Optional Parameters
To handle optional parameters, use the ? operator. You can also set default values.
class Person {
constructor(public name: string, public age?: number) {
if (age === undefined) {
this.age = 0; // Default value
}
}
}
In this example, the age parameter is optional. When creating a Person instance, you can omit the age parameter: new Person("Alice"). If not provided, age will be undefined, which is then set to a default value of 0.
You can also set default values directly in the parameter list:
class Person {
constructor(public name: string, public age: number = 0) {
// No need for explicit undefined check
}
}
This approach is more concise, as TypeScript automatically assigns the default value when the parameter is omitted.
Optional parameters must come after required parameters in the parameter list. This is a TypeScript constraint that ensures function calls don't become ambiguous.
When working with TypeScript interface implementations, optional constructor parameters help create flexible class designs that can adapt to different initialization scenarios.
Using optional parameters in your TypeScript constructor can make your code more robust by accommodating different initialization patterns without requiring multiple constructor overloads.
Constructor Overloading
TypeScript allows you to define multiple constructor signatures to handle different initialization scenarios. Unlike languages like Java or C#, TypeScript uses a single implementation with multiple type signatures.
class Rectangle {
private width: number;
private height: number;
// Constructor overload signatures
constructor(size: number);
constructor(width: number, height: number);
// Single implementation that handles both cases
constructor(widthOrSize: number, height?: number) {
if (height === undefined) {
// Square: single parameter represents both width and height
this.width = widthOrSize;
this.height = widthOrSize;
} else {
// Rectangle: two parameters for width and height
this.width = widthOrSize;
this.height = height;
}
}
getArea(): number {
return this.width * this.height;
}
}
// Both usage patterns are valid
const square = new Rectangle(10); // 10x10 square
const rectangle = new Rectangle(10, 20); // 10x20 rectangle
The overload signatures define the possible ways to call the constructor, while the implementation handles all cases. The implementation signature must be compatible with all overload signatures.
For complex initialization logic with many overload combinations, consider using static factory methods instead. They're often more readable and provide better error messages:
class Rectangle {
private constructor(private width: number, private height: number) {}
static createSquare(size: number): Rectangle {
return new Rectangle(size, size);
}
static createRectangle(width: number, height: number): Rectangle {
return new Rectangle(width, height);
}
}
const square = Rectangle.createSquare(10);
const rectangle = Rectangle.createRectangle(10, 20);
This pattern gives you named constructors that clarify intent and can perform different validation logic for each creation path.
Type Checking in Constructors
TypeScript checks types for constructor parameters at compile time, but you can add runtime validation for more robust logic.
class Person {
constructor(public name: string, public age: number) {
if (typeof age !== 'number') {
throw new Error('Age must be a number');
}
if (age < 0 || age > 120) {
throw new Error('Age must be between 0 and 120');
}
}
}
In this example, the constructor includes runtime checks that validate the age parameter. While TypeScript enforces type checking at compile time, these runtime checks add an extra layer of protection against invalid data.
Type guards like typeof help verify that values match expected types, especially when working with data from external sources that might bypass TypeScript's static type checking.
Validating API Data
When your constructor receives data from an API, you'll want more sophisticated validation:
interface UserApiResponse {
name: string;
email: string;
age?: number;
}
class User {
constructor(
public name: string,
public email: string,
public age: number
) {
this.validateEmail(email);
this.validateAge(age);
}
private validateEmail(email: string): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error(`Invalid email format: ${email}`);
}
}
private validateAge(age: number): void {
if (!Number.isInteger(age) || age < 0) {
throw new Error(`Invalid age: ${age}`);
}
}
static fromApiResponse(data: UserApiResponse): User {
return new User(
data.name,
data.email,
data.age ?? 0
);
}
}
// Usage with API data
const apiData: UserApiResponse = await fetchUserFromApi();
const user = User.fromApiResponse(apiData);
When working with TypeScript readonly properties, type checking in constructors becomes even more important, as these properties cannot be modified after initialization.
Type checking in constructors helps create more reliable code by catching potential issues early in the object lifecycle, making your classes more robust and predictable.
Initializing Properties
TypeScript offers multiple approaches to initialize properties in class constructors, allowing you to choose the method that best fits your coding style and requirements.
class Person {
public name: string;
public age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
In this traditional approach, properties are declared in the class body and then assigned values in the constructor. This pattern provides clear separation between property declaration and initialization.
As we've seen in the parameter properties section, TypeScript also supports a more concise approach:
class Person {
constructor(public name: string, public age: number) {}
}
You can mix both approaches when needed:
class Person {
public address: string;
constructor(public name: string, public age: number, address: string) {
this.address = address.trim(); // Apply transformation during initialization
}
}
This hybrid approach is useful when some properties require additional processing during initialization.
For properties that need a certain value regardless of constructor parameters, you can initialize them directly in the class body:
class Person {
public createdAt: Date = new Date();
constructor(public name: string, public age: number) {}
}
When working with TypeScript abstract class implementations, property initialization patterns ensure that concrete subclasses have a consistent foundation to build upon.
Choosing the right initialization approach helps create clean, maintainable code that clearly communicates your class's structure and behavior.
Implementing Dependency Injection
Dependency injection involves passing dependencies to a class via its constructor, making your code more testable and flexible.
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(message);
}
}
class Person {
constructor(private logger: Logger) {}
public sayHello(): void {
this.logger.log('Hello!');
}
}
In this example, the Person class doesn't create its own logger. Instead, it receives a Logger instance through its constructor. This makes the class more flexible and easier to test, as you can inject different logger implementations:
// Production code
const logger = new ConsoleLogger();
const person = new Person(logger);
// Test code with a mock logger
const mockLogger = { log: (message: string) => { /* Test assertions */ } };
const testPerson = new Person(mockLogger);
This approach follows the Dependency Inversion Principle, one of the SOLID principles of object-oriented design. By depending on abstractions (the Logger interface) rather than concrete implementations, your code becomes more modular and easier to maintain.
Constructor Injection vs. Other Patterns
| Pattern | When to Use | Advantages | Disadvantages |
|---|---|---|---|
| Constructor Injection | Dependencies required for object to function | Clear requirements, immutability, testability | Can lead to large constructors with many parameters |
| Property Injection | Optional dependencies or late binding | Flexibility, simpler constructors | Less clear requirements, mutable state |
| Method Injection | Dependency used for single operation | Maximum flexibility | Repetitive if used frequently |
Constructor injection is the most common and recommended approach because it makes dependencies explicit and ensures objects are always in a valid state.
Constructors are ideal places to implement dependency injection because they ensure that a class always has the dependencies it needs to function properly. This pattern is particularly valuable when working with TypeScript interface implementations in larger applications.
Using dependency injection with TypeScript constructors creates more testable, flexible code that's easier to modify and extend over time.
Private Constructors and Singleton Pattern
Sometimes you want to restrict who can create instances of your class. Private constructors let you control object creation through static methods.
class DatabaseConnection {
private static instance: DatabaseConnection;
private connectionString: string;
// Private constructor prevents external instantiation
private constructor(connectionString: string) {
this.connectionString = connectionString;
console.log('Database connection established');
}
public static getInstance(connectionString: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
public query(sql: string): void {
console.log(`Executing query: ${sql}`);
}
}
// This would cause an error: constructor is private
// const db = new DatabaseConnection('connection-string');
// Correct usage: get the singleton instance
const db1 = DatabaseConnection.getInstance('postgresql://localhost:5432/mydb');
const db2 = DatabaseConnection.getInstance('postgresql://localhost:5432/mydb');
console.log(db1 === db2); // true - same instance
The Singleton pattern ensures only one instance of a class exists throughout your application. This is useful for managing shared resources like database connections, configuration objects, or caches.
Private constructors also work well with static factory methods when you need complex initialization logic:
class Config {
private constructor(
public readonly apiUrl: string,
public readonly timeout: number,
public readonly retryCount: number
) {}
static createDevelopment(): Config {
return new Config('http://localhost:3000', 5000, 3);
}
static createProduction(): Config {
return new Config('https://api.production.com', 30000, 5);
}
static fromEnvironment(): Config {
const env = process.env.NODE_ENV;
return env === 'production'
? Config.createProduction()
: Config.createDevelopment();
}
}
const config = Config.fromEnvironment();
This pattern gives you named constructors with clear intent while preventing direct instantiation that might bypass your initialization logic.
Static Factory Methods
Static factory methods provide an alternative to constructors when you need more control over object creation. They're regular static methods that return instances of the class.
class ApiResponse<T> {
private constructor(
public readonly data: T | null,
public readonly error: string | null,
public readonly status: number
) {}
static success<T>(data: T): ApiResponse<T> {
return new ApiResponse(data, null, 200);
}
static error<T>(message: string, status: number = 500): ApiResponse<T> {
return new ApiResponse(null, message, status);
}
static notFound<T>(): ApiResponse<T> {
return new ApiResponse(null, 'Resource not found', 404);
}
isSuccess(): boolean {
return this.error === null;
}
}
// Clear, expressive usage
const userResponse = ApiResponse.success({ id: 1, name: 'Alice' });
const errorResponse = ApiResponse.error('Invalid credentials', 401);
const notFoundResponse = ApiResponse.notFound();
Static factory methods work well when you have:
- Multiple ways to create objects: Named methods clarify which creation path you're using
- Complex initialization logic: The method can perform validation, transformations, or async operations
- Conditional instantiation: Return different subclass instances based on input
- Caching or pooling: Reuse existing instances instead of always creating new ones
Here's a practical example combining these benefits:
abstract class PaymentProcessor {
protected constructor(protected apiKey: string) {}
abstract processPayment(amount: number): Promise<void>;
static create(provider: 'stripe' | 'paypal', apiKey: string): PaymentProcessor {
switch (provider) {
case 'stripe':
return new StripeProcessor(apiKey);
case 'paypal':
return new PayPalProcessor(apiKey);
default:
throw new Error(`Unknown payment provider: ${provider}`);
}
}
}
class StripeProcessor extends PaymentProcessor {
async processPayment(amount: number): Promise<void> {
console.log(`Processing $${amount} with Stripe`);
}
}
class PayPalProcessor extends PaymentProcessor {
async processPayment(amount: number): Promise<void> {
console.log(`Processing $${amount} with PayPal`);
}
}
// Client code doesn't need to know about specific subclasses
const processor = PaymentProcessor.create('stripe', 'sk_test_key');
await processor.processPayment(100);
This pattern keeps your code flexible. When you add a new payment provider, client code doesn't need to change.
Common Pitfalls to Avoid
When working with TypeScript constructors, watch out for these common mistakes that can cause runtime errors or make your code harder to maintain.
Forgetting to Call super()
Always call super() before using this in a subclass constructor.
class Animal {
constructor(public name: string) {}
}
class Dog extends Animal {
constructor(name: string, public breed: string) {
// Must call super() first
super(name);
// Now it's safe to use 'this'
console.log(`Created a ${this.breed} named ${this.name}`);
}
}
Forgetting to call super() or calling it after using this will result in TypeScript errors.
Mixing Parameter Properties with Manual Assignment
Pick one style and stick with it to avoid confusion:
// Confusing: mixes both styles
class Person {
public address: string;
constructor(
public name: string, // Parameter property
address: string // Regular parameter
) {
this.address = address;
}
}
// Better: consistent parameter properties
class Person {
constructor(
public name: string,
public address: string
) {}
}
Not Validating Constructor Input
Don't assume constructor parameters are valid just because they pass type checking:
class BankAccount {
constructor(public balance: number) {
// Add runtime validation
if (balance < 0) {
throw new Error('Initial balance cannot be negative');
}
}
}
Optional Parameters in Wrong Order
Optional parameters must come after required parameters:
// Wrong: causes TypeScript error
class Config {
constructor(
public timeout?: number,
public retries: number // Error: Required parameter after optional
) {}
}
// Correct: optional parameters at the end
class Config {
constructor(
public retries: number,
public timeout: number = 30000
) {}
}
These techniques help solve common constructor implementation challenges in TypeScript class design, making your code more robust and easier to maintain.
Final Thoughts on TypeScript Constructors
TypeScript constructors give you precise control over object initialization. They're not just about assigning values to properties—they let you validate data, inject dependencies, and ensure your objects always start in a valid state.
The patterns you choose matter. Parameter properties reduce boilerplate for simple cases. Private constructors with static factory methods give you flexibility for complex initialization. Constructor overloading handles multiple creation paths with clear type signatures. And dependency injection through constructors makes your code testable and maintainable.
Whether you're building simple utility classes or complex object hierarchies, effective constructor design prevents initialization bugs and makes your intentions clear to other developers. TypeScript's strong typing system, combined with well-designed constructors, creates a foundation for reliable, type-safe applications.