Skip to main content

TypeScript: Generics and Decorators

Generics

📘 typescriptlang.org > generics

Generics are a powerful feature in TypeScript that allow you to create reusable components that work with multiple data types.

They provide a way to define functions, classes, and interfaces that can work with any type, while still maintaining type safety.

function someFunction<Type>(arg: Type): Type {
return arg;
}

let output = someFunction<string>("Hello"); // type of output will be 'string'

When would this be useful in real life?

Suppose you have a utility function that fetches data from an API and then processes that data. This function should be able to handle different types of data depending on the API endpoint being called. Using generics, you can create a flexible and type-safe function.

async function fetchDataAndProcess<T>(
url: string,
process: (data: T) => void
): Promise<void> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data: T = await response.json();
process(data);
} catch (error) {
console.error("Fetching data failed", error);
}
}

Then we use this utility function to fetch and process data from different API endpoints:

// Define types for the data
interface User {
id: number;
name: string;
email: string;
}

interface Product {
id: number;
name: string;
price: number;
}

// Process functions
function processUserData(data: User): void {
console.log(`User: ${data.name} (${data.email})`);
}

function processProductData(data: Product): void {
console.log(`Product: ${data.name} costs ${data.price}`);
}

// URLs for the APIs
const userApiUrl = "https://api.example.com/user";
const productApiUrl = "https://api.example.com/product";

// Fetch and process user data
fetchDataAndProcess<User>(userApiUrl, processUserData);

// Fetch and process product data
fetchDataAndProcess<Product>(productApiUrl, processProductData);

Explanation

  • Generic Function: fetchDataAndProcess<T> is a generic function where T is a placeholder for the type of data the function will handle. This allows the function to be flexible and reusable for different types of data.

  • Type Safety: When calling fetchDataAndProcess, you specify the type of data it will handle (e.g., User or Product). This ensures that the data fetched from the API is correctly typed, and the processing function receives the correct type.

  • Reusable Logic: The logic for fetching and processing data is encapsulated in a single function, avoiding code duplication and making it easier to maintain.

Valid Identifiers

In TypeScript generics, you can use any valid identifier within the angle brackets (<>). The convention is to use a single uppercase letter like T for simple and general cases. However, for more specific or self-explanatory purposes, you can use more descriptive names such as Type, Item, or even specific names like Pokemon. The key is that the identifier should be clear and meaningful within the context of your code.

Here are a few examples to illustrate this:

Using T

function identity<T>(arg: T): T {
return arg;
}

let output1 = identity<string>("Hello");

Using Type

function identity<Type>(arg: Type): Type {
return arg;
}

let output2 = identity<string>("Hello");

Using a more descriptive name like <Pokemon>

interface Pokemon {
name: string;
type: string;
}

function logPokemonDetails<PokemonType>(pokemon: PokemonType): void {
console.log(pokemon);
}

const pikachu: Pokemon = { name: "Pikachu", type: "Electric" };
logPokemonDetails<Pokemon>(pikachu);

Generic Constraints

📘 typescriptlang.org > generics > constraints

Generic constraints in TypeScript allow you to specify the requirements for the type parameters used in a generic type. These constraints ensure that the type parameter used in a generic type meets certain requirements.

Constraints are specified using the extends keyword, followed by the type that the type parameter must extend or implement.

// create a couple interfaces
interface HasId {
id: number;
}

interface HasName {
name: string;
}

// a couple generic functions with constraints
function logId<T extends HasId>(arg: T): T {
// We know the object has an 'id' property, so we can safely log it
console.log(`ID: ${arg.id}`);
return arg;
}

function logName<T extends HasName>(arg: T): T {
// We know the object has a 'name' property, so we can safely log it
console.log(`Name: ${arg.name}`);
return arg;
}

// using the functions with custom objects
const user = { id: 1, name: "Alice", email: "alice@example.com" };
const product = { id: 101, name: "Widget", price: 9.99 };

logId(user); // Output: ID: 1
logName(user); // Output: Name: Alice

logId(product); // Output: ID: 101
logName(product); // Output: Name: Widget

// The following lines will cause errors because the objects lack the required properties
// logId({ name: "Bob" }); // Error: Property 'id' is missing
// logName({ id: 2 }); // Error: Property 'name' is missing

Decorators

📘 typescriptlang.org > decorators

Decorators are a special kind of declaration in TypeScript that can be attached to a class, method, accessor, property, or parameter. Decorators are a form of meta-programming, providing a way to modify or extend the behavior of the target (class, method, etc.) they are applied to. They are often used in frameworks such as Angular and NestJS to add metadata or modify class behavior.

warning

Decorators are an experimental feature in TypeScript and may change in future versions. To enable decorators in your TypeScript project, you need to set "experimentalDecorators": true in your tsconfig.json file.

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

Let's create a decorator that creates some useful logs for use

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

descriptor.value = function (...args: any[]) {
console.log(
`Executing ${propertyKey} with arguments: ${JSON.stringify(args)}`
);
const result = originalMethod.apply(this, args);
console.log(
`Executed ${propertyKey} and returned: ${JSON.stringify(result)}`
);
return result;
};

return descriptor;
}

Now we can apply this decorator to a class method to log its execution:

class ExampleService {
@LogExecution
add(a: number, b: number): number {
return a + b;
}

@LogExecution
greet(name: string): string {
return `Hello, ${name}!`;
}
}

const service = new ExampleService();
console.log(service.add(2, 3)); // Output: Logs the execution and result of the add method
console.log(service.greet("Alice")); // Output: Logs the execution and result of the greet method

Explanation

  1. Decorator Function: LogExecution is a decorator function that takes three parameters: target, propertyKey, and descriptor. The descriptor parameter allows access to the method's metadata.
  2. Modifying the Descriptor: Inside the decorator, we save the original method and then modify the method descriptor to log before and after the method execution.
  3. Applying the Decorator: The @LogExecution decorator is applied to the methods add and greet. When these methods are called, the decorator logs their execution details.

Automated Amazon Reports

Automatically download Amazon Seller and Advertising reports to a private database. View beautiful, on demand, exportable performance reports.

bidbear.io