Generic Interfaces and Classes

Table of Contents

  • Introduction
  • What Are Generic Interfaces?
  • Defining a Generic Interface
  • Using Generic Interfaces with Different Types
  • Generic Classes in TypeScript
  • Defining a Generic Class
  • Using Generic Classes with Multiple Types
  • Constraints in Generic Interfaces and Classes
  • Example 1: A Generic Container Class
  • Example 2: A Generic Logging Interface
  • Best Practices for Using Generic Interfaces and Classes
  • Conclusion

Introduction

In TypeScript, generics are not limited to just functions. You can also define generic interfaces and generic classes, which enable you to write flexible, reusable, and type-safe code for a wide range of data structures and object types. Both generic interfaces and classes allow you to define templates for data types that can be reused throughout your code, offering strong typing and preventing common runtime errors.

In this article, we will dive into generic interfaces and generic classes, explore their syntax and usage, and showcase practical examples to help you understand how they work.


What Are Generic Interfaces?

A generic interface is an interface that can take a type parameter, allowing the interface to define a contract for various types. When you use a generic interface, you can specify the type that it should work with, making the interface flexible while still retaining type safety.

Generic interfaces are commonly used when defining collections or structures that need to work with different data types, like arrays, linked lists, or containers.


Defining a Generic Interface

To define a generic interface in TypeScript, you use angle brackets (<T>) to specify a type parameter inside the interface declaration. This type parameter will be used to define the types of the properties or methods in the interface.

Basic Syntax

interface Box<T> {
value: T;
getValue: () => T;
}

In this example:

  • The Box interface has a type parameter T, which represents the type of the value property.
  • The getValue method also returns a value of type T.

Using the Generic Interface

When you create an object based on the Box interface, you can specify the type for T. TypeScript will then infer and enforce the type for the properties and methods accordingly.

const numberBox: Box<number> = {
value: 42,
getValue: function () {
return this.value;
},
};

const stringBox: Box<string> = {
value: 'Hello, TypeScript!',
getValue: function () {
return this.value;
},
};

Here, numberBox is a Box that holds a number, and stringBox is a Box that holds a string.


Using Generic Interfaces with Different Types

You can also define more complex generic interfaces that work with multiple types. TypeScript allows you to define multiple type parameters in a generic interface.

Example: Pairing Two Values

interface Pair<T, U> {
first: T;
second: U;
}

const numberStringPair: Pair<number, string> = {
first: 1,
second: 'apple',
};

const stringBooleanPair: Pair<string, boolean> = {
first: 'isActive',
second: true,
};

In this example:

  • The Pair interface has two type parameters, T and U.
  • numberStringPair holds a number and a string.
  • stringBooleanPair holds a string and a boolean.

Generic Classes in TypeScript

A generic class is a class that can work with multiple types by defining a type parameter. This is useful when you want to create a class that works with different types of data, but still wants to ensure that the types are correct.

Just like with interfaces, you can define a generic class using the angle bracket syntax <T>, and then use T within the class to define the types of properties or methods.


Defining a Generic Class

Here’s the syntax for a basic generic class:

class Container<T> {
private value: T;

constructor(value: T) {
this.value = value;
}

getValue(): T {
return this.value;
}
}

In this example:

  • Container is a generic class that takes a type parameter T.
  • The class has a private property value of type T, and a method getValue that returns the value of type T.

Using Generic Classes with Multiple Types

You can also define a class that works with multiple type parameters, allowing you to create more complex data structures.

Example: A Generic Key-Value Store

class KeyValueStore<K, V> {
private store: Map<K, V>;

constructor() {
this.store = new Map();
}

set(key: K, value: V): void {
this.store.set(key, value);
}

get(key: K): V | undefined {
return this.store.get(key);
}
}

const stringNumberStore = new KeyValueStore<string, number>();
stringNumberStore.set('age', 25);
console.log(stringNumberStore.get('age')); // Output: 25

const numberStringStore = new KeyValueStore<number, string>();
numberStringStore.set(1, 'apple');
console.log(numberStringStore.get(1)); // Output: 'apple'

In this example:

  • The KeyValueStore class has two type parameters, K and V, which represent the key and value types respectively.
  • We can create a KeyValueStore that stores string keys and number values, or one that stores number keys and string values.

Constraints in Generic Interfaces and Classes

Just like generic functions, generic interfaces and classes can also have constraints. Constraints ensure that the type used in the generic type parameter adheres to a certain shape, allowing you to restrict the types that can be used.

Example: Constrained Generic Interface

interface Lengthwise {
length: number;
}

interface Container<T extends Lengthwise> {
value: T;
getLength(): number;
}

const stringContainer: Container<string> = {
value: 'Hello',
getLength() {
return this.value.length;
},
};

const arrayContainer: Container<number[]> = {
value: [1, 2, 3],
getLength() {
return this.value.length;
},
};

// const numberContainer: Container<number> // Error: number doesn't have a length property

In this example:

  • The Container interface has a constraint on T (T extends Lengthwise), ensuring that only types with a length property (like string or Array) can be used.

Example: Constrained Generic Class

class Box<T extends { length: number }> {
private value: T;

constructor(value: T) {
this.value = value;
}

getLength(): number {
return this.value.length;
}
}

const boxWithArray = new Box([1, 2, 3]); // Valid
const boxWithString = new Box('Hello'); // Valid
// const boxWithNumber = new Box(42); // Error: number doesn't have a length property

Here:

  • The Box class has a constraint on T that ensures it can only be used with types that have a length property.

Example 1: A Generic Container Class

class Container<T> {
private value: T;

constructor(value: T) {
this.value = value;
}

getValue(): T {
return this.value;
}

setValue(value: T): void {
this.value = value;
}
}

const numberContainer = new Container<number>(100);
console.log(numberContainer.getValue()); // Output: 100
numberContainer.setValue(200);
console.log(numberContainer.getValue()); // Output: 200

Example 2: A Generic Logging Interface

interface Logger<T> {
log(value: T): void;
}

class ConsoleLogger<T> implements Logger<T> {
log(value: T): void {
console.log(value);
}
}

const stringLogger = new ConsoleLogger<string>();
stringLogger.log('Hello, World!'); // Output: Hello, World!

const numberLogger = new ConsoleLogger<number>();
numberLogger.log(42); // Output: 42

Best Practices for Using Generic Interfaces and Classes

  1. Be Descriptive with Type Parameters: Use meaningful names for type parameters (e.g., T becomes ItemType or KeyType) to improve code readability.
  2. Use Constraints: Use constraints to limit the types that can be used with generics when necessary. This ensures type safety while maintaining flexibility.
  3. Avoid Overuse of Generics: While generics are powerful, excessive use can make code harder to understand. Use generics when they make your code more reusable and flexible, but avoid overcomplicating it.
  4. Keep Generic Methods Simple: If you’re writing methods within generic classes or interfaces, keep them simple and focused on the specific functionality needed.

Conclusion

Generic interfaces and classes in TypeScript provide a powerful way to create flexible, reusable, and type-safe code that works with various types of data. By using generics, you can ensure that your classes and interfaces can adapt to different data types while still retaining the benefits of type safety.

With these tools, you can write more maintainable and modular code that will scale as your application grows.