Introduction
TypeScript has become the go-to language for large-scale JavaScript applications, offering static typing, better tooling, and improved developer experience. While its basic features provide a solid foundation, mastering advanced TypeScript techniques—such as Generics, Decorators, and Type Inference—is essential for writing highly scalable, reusable, and maintainable code.
The Need for Advanced TypeScript
When working on complex applications, developers often encounter challenges that require more than just basic types. These include:
Reusability & Scalability – Writing reusable and scalable functions, components, and classes becomes difficult without generics.
Code Abstraction & Flexibility – TypeScript's type inference and advanced typing mechanisms enable better abstraction.
Performance Optimization – TypeScript features like decorators allow for metaprogramming and reducing boilerplate.
Better Code Maintainability – Using advanced TypeScript features helps maintain cleaner and more structured codebases.
Understanding Generics in TypeScript
What are Generics?
Generics in TypeScript provide a way to create reusable, flexible, and type-safe components, functions, and classes. They allow us to define type parameters that can be dynamically assigned when the function, class, or interface is used.
Why Use Generics?
Reusability – Write once, use with multiple types.
Type Safety – Ensures type correctness while avoiding any.
Flexibility – Allows using multiple types dynamically instead of hardcoding them.
Scalability – Useful for building generic data structures, APIs, and utility functions.
Example Without Generics
function identity(value: number): number {
return value;
}
Here, this function works only with number. What if we want it to work with string, boolean, or any other type?
Same Function Using Generics
function identity<T>(value: T): T {
return value;
}
Now, identity<T> can take any type T and return the same type.
Basic Generic Functions
A generic function allows us to use a placeholder type instead of a fixed type. The T in the function signature represents a type variable, which will be replaced by the actual type at runtime.
Example: Generic Function
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
// Usage
const num = getFirstElement([1, 2, 3]); // inferred as number
const str = getFirstElement(["apple", "banana"]); // inferred as string
TypeScript infers the type automatically, eliminating the need for explicit typing.
Generic Interfaces and Types
Just like functions, we can define generic interfaces and types to create reusable type-safe data structures.
Example: Generic Interface for an API Response
interface ApiResponse<T> {
success: boolean;
data: T;
}
const userResponse: ApiResponse<{ id: number; name: string }> = {
success: true,
data: { id: 1, name: "Divyansh" },
};
const productResponse: ApiResponse<{ id: number; price: number }> = {
success: true,
data: { id: 1001, price: 499 },
};
The ApiResponse<T> allows flexibility in defining data, making it reusable across different API endpoints.
Example: Generic Type Alias
type Box<T> = { value: T };
const stringBox: Box<string> = { value: "Hello" };
const numberBox: Box<number> = { value: 42 };
Box<T> can hold any type dynamically.
Generic Constraints
Sometimes, we need to restrict the types that a generic function or class can accept. TypeScript allows constraints using the extends keyword.
Example: Constraining Generics
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
// Works because 'string' and arrays have a 'length' property
console.log(getLength("Hello")); // 5
console.log(getLength([1, 2, 3])); // 3
// Error: Number doesn't have a length property
// console.log(getLength(10));
Here, T extends { length: number } ensures that T has a length property.
Example: Using Constraints with Interfaces
interface HasId {
id: number;
}
function getId<T extends HasId>(obj: T): number {
return obj.id;
}
// Valid: Object has an id
console.log(getId({ id: 101, name: "Divyansh" })); // 101
// Invalid: Object does not have an id
// console.log(getId({ name: "No ID" })); // Error
This ensures that only objects with an id property can be passed.
Mastering Decorators in TypeScript
What Are Decorators?
Decorators in TypeScript are a powerful metaprogramming feature that allows us to modify or enhance classes, methods, properties, or parameters at runtime. They enable code reuse, abstraction, and modularity by dynamically adding behaviors without modifying the original class.
Key Features of Decorators
Enhance class behaviour without modifying the class directly.
Attach metadata to classes, methods, properties, and parameters.
Enable Dependency Injection, Logging, and Performance Monitoring.
Used in frameworks like Angular, NestJS, and TypeORM.
How to Enable Decorators in TypeScript?
Since decorators are an experimental feature, you need to enable them in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
experimentalDecorators: Enables decorators.
emitDecoratorMetadata: Stores metadata for reflection-based libraries (e.g., NestJS, TypeORM).
Types of Decorators in TypeScript
Decorators can be applied to different parts of a class:
Class Decorators → Modify class behavior.
Method Decorators → Enhance method behavior.
Property Decorators → Modify class properties.
Parameter Decorators → Modify method parameters.
Let's explore each in detail.
Class Decorators
A class decorator is applied to a class to modify its behavior. It takes the class constructor as an argument.
Example: Adding Metadata to a Class
function Logger(target: Function) {
console.log(`Logging: ${target.name}`);
}
@Logger
class User {
constructor(public name: string) {}
}
Output:
Logging: User
The Logger decorator logs the class name when the class is defined.
Example: Modifying Class Behavior
function AddTimestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
timestamp = new Date();
};
}
@AddTimestamp
class Order {
constructor(public id: number) {}
}
const order = new Order(101);
console.log(order);
// Order { id: 101, timestamp: 2025-03-10T10:00:00.000Z }
The decorator adds a timestamp property dynamically.
Method Decorators
A method decorator is applied to a class method to modify its behavior.
Example: Logging Method Calls
function Log(target: any, methodName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${methodName} with args:`, args);
return originalMethod.apply(this, args);
};
return descriptor;
}
class Calculator {
@Log
add(a: number, b: number) {
return a + b;
}
}
const calc = new Calculator();
console.log(calc.add(5, 3));
// Logs: Calling add with args: [5, 3]
// Output: 8
The Log decorator logs method calls with their arguments.
Property Decorators
A property decorator is applied to a class property to modify or enforce behavior.
Example: Read-Only Property
function ReadOnly(target: any, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
writable: false,
configurable: false
});
}
class UserProfile {
@ReadOnly
username = "Divyansh";
}
const user = new UserProfile();
// user.username = "NewName"; // Error: Cannot assign to 'username'
The ReadOnly decorator prevents modification of the property.
Example: Enforcing Number Property
function EnsureNumber(target: any, propertyKey: string) {
let value: any;
Object.defineProperty(target, propertyKey, {
get: () => value,
set: (newVal) => {
if (typeof newVal !== "number") {
throw new Error(`${propertyKey} must be a number`);
}
value = newVal;
},
});
}
class Product {
@EnsureNumber
price: number;
}
const item = new Product();
item.price = 99; // Works
// item.price = "free"; // Error: price must be a number
The EnsureNumber decorator ensures the property is always a number.
Parameter Decorators
A parameter decorator is applied to function parameters to extract metadata.
Example: Validating Arguments
function Validate(target: any, methodName: string, paramIndex: number) {
console.log(`Parameter ${paramIndex} of ${methodName} is being validated.`);
}
class Payment {
process(@Validate amount: number) {
console.log(`Processing amount: ${amount}`);
}
}
const pay = new Payment();
pay.process(500);
// Logs: Parameter 0 of process is being validated.
The Validate decorator logs parameter usage.
Creating Custom Decorators
We can create custom decorators to inject behavior dynamically.
Example: Role-Based Access Control
function Role(allowedRole: string) {
return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (role: string, ...args: any[]) {
if (role !== allowedRole) {
throw new Error(`Unauthorized: ${role} cannot access ${methodName}`);
}
return originalMethod.apply(this, args);
};
};
}
class Dashboard {
@Role("admin")
deleteUser(role: string, userId: number) {
console.log(`User ${userId} deleted.`);
}
}
const admin = new Dashboard();
admin.deleteUser("admin", 101); // Works
// admin.deleteUser("guest", 101); // Error: Unauthorized
This decorator restricts method access based on roles.
Using Decorators for Logging, Caching, and Performance Monitoring
Decorators can be used to enhance performance, log events, and implement caching.
Logging Function Calls
function LogExecution(target: any, methodName: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.time(methodName);
const result = original.apply(this, args);
console.timeEnd(methodName);
return result;
};
}
class Service {
@LogExecution
fetchData() {
for (let i = 0; i < 1e6; i++); // Simulate delay
return "Data fetched";
}
}
const api = new Service();
api.fetchData();
// Logs execution time
Caching Results
function Cache(target: any, methodName: string, descriptor: PropertyDescriptor) {
const cache = new Map();
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = original.apply(this, args);
cache.set(key, result);
return result;
};
}
class MathOperations {
@Cache
square(n: number) {
return n * n;
}
}
const math = new MathOperations();
console.log(math.square(5)); // Calculates
console.log(math.square(5)); // Returns cached value
Performance boost! Subsequent calls return cached results.
Experimental Status and Compatibility Considerations
Decorators are still experimental in TypeScript.
They require "experimentalDecorators": true in tsconfig.json.
ESNext proposal: JavaScript may adopt decorators officially.
Alternative Approaches: If decorators are unavailable, use higher-order functions.
Deep Dive into Type Inference in TypeScript
What is Type Inference?
Type inference is TypeScript’s ability to automatically determine the type of a variable, function, or expression without explicit type annotations. Instead of manually specifying types, TypeScript infers them based on how values are assigned and used.
Why is Type Inference Important?
Reduces Boilerplate – No need to manually specify types everywhere.
Improves Readability – Code remains clean and easy to understand.
Maintains Type Safety – Even without explicit annotations, TypeScript prevents incorrect type assignments.
Enhances Developer Productivity – Allows focus on logic rather than type definitions.
How TypeScript Infers Types Automatically
TypeScript infers types based on initialization, return values, and context.
1. Inferring Types from Initialization
When a variable is assigned a value during initialization, TypeScript infers its type.
let num = 10; // inferred as number
let text = "Hello"; // inferred as string
let isActive = true; // inferred as boolean
Here, TypeScript assigns number, string, and boolean types without explicit annotations.
2. Inferring Function Return Types
If a function has a return statement, TypeScript infers the return type automatically.
function add(a: number, b: number) {
return a + b; // TypeScript infers the return type as 'number'
}
The return type of add is inferred as number, so we don’t need : number explicitly.
3. Inferring Types from Function Parameters
If a function parameter has a default value, TypeScript infers its type.
function greet(name = "Guest") {
return `Hello, ${name}`;
}
The name parameter is inferred as string because of the default value "Guest".
4. Inferring Array and Object Types
let numbers = [1, 2, 3]; // inferred as number[]
let user = { name: "Alice", age: 25 }; // inferred as { name: string; age: number }
TypeScript infers arrays as number[] and objects based on their properties.
Understanding Contextual Typing
TypeScript also infers types based on the surrounding context, which is called contextual typing.
Example: Contextual Typing in Event Listeners
document.addEventListener("click", (event) => {
console.log(event.clientX, event.clientY); // 'event' is inferred as 'MouseEvent'
});
Here, TypeScript infers that event is a MouseEvent without explicit annotation.
Example: Contextual Typing in Callbacks
const numbers = [1, 2, 3];
numbers.forEach((num) => console.log(num.toFixed(2))); // 'num' inferred as 'number'
num is inferred as number because forEach iterates over number[].
Type Widening and Narrowing
TypeScript adjusts types dynamically based on how they are initialized and used.
Type Widening
TypeScript initially assigns a broad type (wider type) and later narrows it down based on usage.
Example: Type Widening
let value = null;
value = "Hello"; // value is widened to string | null
Initially, value is null, but TypeScript widens its type to string | null when a string is assigned.
Example: Widening in Arrays
let arr = [1, "text", true];
// inferred as (string | number | boolean)[]
TypeScript infers the broadest common type: string | number | boolean.
Type Narrowing
TypeScript refines a broad type to a more specific type using type checks and conditions.
Example: Using typeof to Narrow Type
function process(input: number | string) {
if (typeof input === "string") {
return input.toUpperCase(); // Type narrowed to string
}
return input * 2; // Type narrowed to number
}
input starts as number | string, but TypeScript narrows it within if statements.
Example: Narrowing with Type Guards
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark(); // TypeScript infers 'animal' as Dog
} else {
animal.meow(); // TypeScript infers 'animal' as Cat
}
}
TypeScript automatically narrows the type based on instanceof.
Advanced Inference Scenarios
1. Inferring Type From Generic Functions
TypeScript can infer types when using generics.
Example: Generic Function with Inference
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // Inferred as number
const str = identity("Hello"); // Inferred as string
The identity function infers T dynamically based on input.
2. Inferring Return Types in Higher-Order Functions
When passing functions as arguments, TypeScript infers their types.
function transform<T, U>(value: T, fn: (input: T) => U): U {
return fn(value);
}
const length = transform("TypeScript", (str) => str.length); // inferred as number
The function infers T = string and U = number dynamically.
3. Inferring Type in Mapped Types
TypeScript can infer types when transforming object types.
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type User = { name: string; age: number };
type ReadonlyUser = Readonly<User>;
// Inferred as { readonly name: string; readonly age: number }
TypeScript infers property types dynamically.
4. Inferring Function Overloads
When using function overloads, TypeScript infers the correct return type.
function getId(id: number): number;
function getId(id: string): string;
function getId(id: number | string) {
return id;
}
const userId = getId(101); // inferred as number
const productId = getId("P001"); // inferred as string
TypeScript correctly infers return types based on input.
Combining Generics, Decorators, and Type Inference in TypeScript
When working on large-scale applications, it's crucial to write reusable, scalable, and efficient code. TypeScript’s Generics, Decorators, and Type Inference can be combined to achieve highly flexible and type-safe solutions.
In this section, we’ll explore how they work together and how to build a generic decorator that leverages type inference.
How Generics, Decorators, and Type Inference Work Together
Each of these features contributes uniquely:
Generics → Allow us to write flexible and reusable functions, classes, and types.
Decorators → Enable metaprogramming by modifying or extending class behavior.
Type Inference → Reduces the need for explicit type annotations, making code cleaner.
When combined, they allow us to create decorators that dynamically adapt to different types, reducing redundancy and improving maintainability.
Example: Combining Generics, Decorators, and Type Inference
Let's take an example where we:
Create a generic class that can store data.
Use decorators to add logging and metadata dynamically.
Leverage type inference to ensure the correct types are inferred.
// Generic class that can store any type of data
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
getItems(): T[] {
return this.data;
}
}
// Decorator to log method calls dynamically
function LogMethod<T>(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<(...args: any[]) => T>
) {
const originalMethod = descriptor.value!;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${methodName} with arguments:`, args);
const result = originalMethod.apply(this, args);
console.log(`Result:`, result);
return result;
};
return descriptor;
}
// Applying the decorator to a method inside the generic class
class ProductStorage extends DataStorage<string> {
@LogMethod
addItem(item: string) {
super.addItem(item);
}
}
const productStore = new ProductStorage();
productStore.addItem("Laptop"); // Logs method call and result
What’s happening here?
DataStorage<T> is generic, meaning it can work with any type.
LogMethod<T> is a generic decorator that logs method calls dynamically.
Type inference ensures the correct type (string in this case) is used throughout.
Building a Generic Decorator with Type Inference
Now, let’s build a more advanced generic decorator that:
Works with multiple data types dynamically.
Leverages type inference to determine return types.
Applies additional functionality dynamically.
Example: Generic Class Decorator for Caching
We'll create a decorator that adds caching to methods of a generic class.
// Generic Cache Decorator
function Cache<T>(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<(...args: any[]) => T>
) {
const originalMethod = descriptor.value!;
const cache = new Map<string, T>();
descriptor.value = function (...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for ${methodName} with args:`, args);
return cache.get(key);
}
console.log(`Cache miss for ${methodName}. Computing result...`);
const result = originalMethod.apply(this, args);
cache.set(key, result);
return result;
};
return descriptor;
}
// Generic class using the Cache Decorator
class MathOperations {
@Cache
computeSquare(n: number): number {
return n * n;
}
}
const math = new MathOperations();
console.log(math.computeSquare(5)); // Computation occurs
console.log(math.computeSquare(5)); // Cached result returned
How it works:
Generics → Cache<T> allows caching for any return type dynamically.
Decorators → @Cache decorates computeSquare(), caching its results.
Type Inference → Infers number as return type from function logic.
Example: Generic Decorator for API Response Handling
Let’s build a decorator that:
Wraps a method’s response in a generic ApiResponse<T> type.
Infers the response type dynamically based on method return type.
// Generic API Response Wrapper
function ApiResponse<T>(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<(...args: any[]) => T>
) {
const originalMethod = descriptor.value!;
descriptor.value = async function (...args: any[]) {
try {
const result = await originalMethod.apply(this, args);
return { success: true, data: result };
} catch (error) {
return { success: false, error: error.message };
}
};
return descriptor;
}
// Example API service using the generic decorator
class UserService {
@ApiResponse
async fetchUser(userId: number) {
// Simulating API call
if (userId === 0) throw new Error("User not found");
return { id: userId, name: "John Doe" };
}
}
const userService = new UserService();
userService.fetchUser(1).then(console.log); // { success: true, data: { id: 1, name: "John Doe" } }
userService.fetchUser(0).then(console.log); // { success: false, error: "User not found" }
How it works:
Generics → ApiResponse<T> adapts to any function return type.
Decorators → @ApiResponse wraps function calls, handling success & errors.
Type Inference → Automatically infers the correct response type.
Advanced TypeScript Patterns Using Generics, Decorators, and Type Inference
TypeScript's Generics, Decorators, and Type Inference allow us to implement advanced software design patterns in a type-safe and reusable manner. These patterns are essential for building scalable, high-performance applications.
Factory Pattern with Generics
The Factory Pattern is a creational design pattern that provides a way to create objects without specifying their exact class type. By using Generics, we can ensure type safety while maintaining flexibility.
Example: Generic Factory for Object Creation
// Generic Factory Function
function createInstance<T>(ClassType: new (...args: any[]) => T, ...args: any[]): T {
return new ClassType(...args);
}
// Example Classes
class User {
constructor(public name: string, public age: number) {}
}
class Product {
constructor(public id: number, public price: number) {}
}
// Using the Factory Function
const user = createInstance(User, "Divyansh", 28);
console.log(user); // User { name: 'Divyansh', age: 28 }
const product = createInstance(Product, 101, 499);
console.log(product); // Product { id: 101, price: 499 }
Why this works?
Generics ensure that the function works for any class type.
Type Inference automatically determines the type of created instances.
Flexibility allows us to instantiate any class without modifying the factory function.
Singleton Pattern with Decorators
The Singleton Pattern ensures that a class has only one instance and provides a global access point to it. Using Decorators, we can enforce this behaviour dynamically.
Example: Singleton Decorator
// Singleton Decorator
function Singleton<T extends { new (...args: any[]): {} }>(constructor: T) {
let instance: T;
return class extends constructor {
constructor(...args: any[]) {
if (!instance) {
instance = new constructor(...args);
}
return instance;
}
};
}
// Applying Singleton to a Database Connection Class
@Singleton
class Database {
private connectionId: number;
constructor() {
this.connectionId = Math.random();
console.log(`New Database Connection Created: ${this.connectionId}`);
}
getId() {
return this.connectionId;
}
}
const db1 = new Database();
const db2 = new Database();
console.log(db1.getId() === db2.getId()); // true (Same instance)
How it works?
The Singleton decorator ensures that Database always returns the same instance.
The first time an instance is created, it is stored in instance.
On subsequent calls, the stored instance is returned instead of creating a new one.
Fluent API Design with Type Inference
A Fluent API allows method chaining to improve readability and make APIs more expressive. TypeScript's Type Inference and Generics enable this pattern seamlessly.
Example: Fluent API for Building SQL Queries
class QueryBuilder {
private query: string = "";
select(columns: string[]): this {
this.query += `SELECT ${columns.join(", ")} `;
return this;
}
from(table: string): this {
this.query += `FROM ${table} `;
return this;
}
where(condition: string): this {
this.query += `WHERE ${condition} `;
return this;
}
build(): string {
return this.query.trim() + ";";
}
}
// Using Fluent API
const query = new QueryBuilder()
.select(["id", "name"])
.from("users")
.where("age > 21")
.build();
console.log(query); // SELECT id, name FROM users WHERE age > 21;
Why this works?
Type Inference ensures method chaining works correctly.
The return type this allows for chaining methods while keeping the correct type.
This pattern is widely used in libraries like Sequelize, Knex, and Lodash.
Conclusion
Throughout this article, we've explored some of the most powerful features in TypeScript—Generics, Decorators, and Type Inference—and demonstrated how they can be leveraged to build flexible, scalable, and high-performance applications.