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 whereT
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
orProduct
). 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.
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
- Decorator Function:
LogExecution
is a decorator function that takes three parameters:target
,propertyKey
, anddescriptor
. Thedescriptor
parameter allows access to the method's metadata. - 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.
- Applying the Decorator: The
@LogExecution
decorator is applied to the methodsadd
andgreet
. When these methods are called, the decorator logs their execution details.
Comments
Recent Work
Basalt
basalt.softwareFree desktop AI Chat client, designed for developers and businesses. Unlocks advanced model settings only available in the API. Includes quality of life features like custom syntax highlighting.
BidBear
bidbear.ioBidbear is a report automation tool. It downloads Amazon Seller and Advertising reports, daily, to a private database. It then merges and formats the data into beautiful, on demand, exportable performance reports.