TypeScript has become an integral part of modern web development, offering robust type-checking and enhanced tooling. Among its powerful features, generics stand out as a crucial concept that every TypeScript developer should master. In this post, we’ll explore generics in depth - an important feature that allows you to write flexible, reusable, and type-safe code. But before we dive into generics, let’s review the everyday types in TypeScript to set the stage.
- The Foundation: Everyday Types in TypeScript
- Beyond Basics: Objects, Arrays, and Functions in TypeScript
- Understanding Generics in TypeScript: The Need for Flexible Types
- Generics to The Rescue
- Advanced Features of Generics in TypeScript
- Learn By Examples
- Conclusion
The Foundation: Everyday Types in TypeScript
Before diving into generics, it’s worth reviewing the everyday types that form the foundation of TypeScript’s type system. These include number, string, boolean, null, and undefined. While these types are fundamental and cover many use cases, they often fall short when dealing with complex type manipulations in real-world applications. Let’s look at a quick example of these everyday types in action:
// Numeric values
let count: number = 10;
// Textual data
let name: string = "TypeScript";
// true/false values
let isCompleted: boolean = false;
// Intentional absense of any object value
let data: null = null;
// A variable that hasn't been assiged a value
let result: undefined = undefined;
// An example usage
function processUser(
id: number,
name: string,
isActive: boolean
): string | null {
if (isActive) {
return `User ${name} with ID ${id} is active`;
}
return null;
}
Beyond Basics: Objects, Arrays, and Functions in TypeScript
While the everyday types cover many basic scenarios, TypeScript truly shines when working with more complex structures like objects, arrays, and functions. These constructs form the backbone of most TypeScript applications and offer powerful ways to structure and manipulate data.
Objects
In TypeScript, objects can be typed using interfaces or type aliases, allowing you to define the shape of your data:
// Shape of the User object
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
// Using user object
const user: User = {
id: 1,
name: "John Doe",
email: "john@example.com",
isActive: true
};
Arrays
Arrays in TypeScript can be typed to ensure they contain elements of a specific type:
// Array of numbers
const scores: number[] = [85, 92, 78, 90];
// Alternative syntax
const names: Array<string> = ["Alice", "Bob", "Charlie"];
// Array of objects
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com", isActive: true },
{ id: 2, name: "Bob", email: "bob@example.com", isActive: false }
];
Functions
TypeScript allows you to specify the types of function parameters and return values, enhancing code reliability:
// Function with typed parameters and return value
function addNumbers(a: number, b: number): number {
return a + b;
}
// Arrow function with object parameter and union type return
const getUserStatus = (user: User): "active" | "inactive" => {
return user.isActive ? "active" : "inactive";
};
// Function type definition
type MathOperation = (x: number, y: number) => number;
const multiply: MathOperation = (a, b) => a * b;
Understanding Generics in TypeScript: The Need for Flexible Types
Why Generics are Needed
To understand why generics are necessary, let’s start with a simple identity function that returns whatever is passed into it. Without generics, we might write it like this:
function identity(arg: any): any {
return arg;
}
let output1 = identity("myString"); // type of output1 is 'any'
let output2 = identity(42); // type of output2 is 'any'
In this example, we use any as the type for the argument and return value. While this function works, it loses type information. We can’t be sure what type of value it will return.
We could also use unknown:
function identity(arg: unknown): unknown {
return arg;
}
let output = identity("myString"); // type of output is 'unknown'
// We need to check the type before using output
if (typeof output === "string") {
console.log(output.toUpperCase()); // OK
}
Using unknown is safer than any, but it still requires type checking before we can use the returned value.
These approaches have limitations:
With
any, we lose type safety entirely.With
unknown, we maintain type safety but lose specific type information, requiring type checks or assertions before use.
Generics to The Rescue
This is where generics come in. Generics allow us to capture the type of the argument in a way that we can also use it to denote the return type:
function identity<T>(arg: T): T {
return arg;
}
let output1 = identity("myString"); // type of output1 is 'string'
let output2 = identity(42); // type of output2 is 'number'
Now, we’ve created a generic identity function that preserves and returns the exact type of the argument it receives. This approach combines flexibility with type safety, allowing the function to work with any type while still providing precise type information.
Advanced Features of Generics in TypeScript
Generics in TypeScript offer more than just basic type parameterization. They come with powerful features that allow for more precise type constraints, default types, and conditional type selection. Let’s explore these advanced features:
Extending Types in Generics
The extends keyword in generics allows you to constrain the types that can be used with a generic. This is useful when you want to ensure that the type parameter has certain properties or methods:
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // Now we know it has a .length property
return arg;
}
loggingIdentity([1, 2, 3]); // OK - Array.length()
loggingIdentity("hello"); // OK - String.length()
loggingIdentity({ length: 10, value: 3 }); // OK - The object has `length` property
// loggingIdentity(3); // Error, number doesn't have a .length property. Number.length() is not valid
In this example, T extends Lengthwise ensures that the type T must have a length property of type number.
Default Type Parameters
TypeScript allows you to specify default types for generic type parameters. This is particularly useful when the generic type is not explicitly provided:
interface DefaultDict<T = string> {
[key: string]: T;
}
const stringDict: DefaultDict = { "key": "value" }; // T defaults to string
const numberDict: DefaultDict<number> = { "key": 42 }; // T is explicitly set to number
In this example, if no type is specified for DefaultDict, it defaults to using string as the value type.
Conditional Types in Generics
Conditional types allow you to select types based on a condition. This is a powerful feature that enables complex type relationships:
type NonNullable<T> = T extends null | undefined ? never : T;
type ResultType = NonNullable<string | null | undefined>; // ResultType is string
type ExtractArray<T> = T extends Array<infer U> ? U : never;
type ElementType = ExtractArray<number[]>; // ElementType is number
type NoArrayType = ExtractArray<string>; // NoArrayType is never
In these examples:
NonNullableremovesnullandundefinedfrom a union type.ExtractArrayextracts the element type from an array type, or returnsneverif the type is not an array.
These advanced features of generics provide powerful tools for creating flexible and precise type definitions in TypeScript. They allow you to write more expressive and type-safe code, handling complex scenarios while maintaining strong typing.
Learn By Examples
To truly grasp the power and flexibility of generics in TypeScript, let’s walk through some practical examples. We’ll start with simple cases and gradually move to more complex scenarios.
Example 1: Generic Function
Let’s begin with a simple generic function that reverses an array:
function reverseArray<T>(array: readonly T[]): T[] {
return [...array].reverse();
}
// Usage
const numbers = [1, 2, 3, 4, 5] as const;
const reversedNumbers = reverseArray(numbers);
console.log(reversedNumbers); // Output: [5, 4, 3, 2, 1]
const fruits = ["apple", "banana", "cherry"] as const;
const reversedFruits = reverseArray(fruits);
console.log(reversedFruits); // Output: ["cherry", "banana", "apple"]
In this example:
<T>declares a type parameterT.readonly T[]as the parameter type means the function accepts a readonly array of any type, ensuring we don’t modify the input.We use the spread operator
[...array]to create a new array, then reverse it.The function returns
T[], which is a new array of the same type.We use
as constto create readonly arrays in our usage examples.
This generic function can work with arrays of any type, maintaining type safety throughout, and it doesn’t mutate the original array.
Example 2: Generic Interface
Now, let’s look at a generic interface for a simple key-value pair:
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// Usage
const stringNumberPair: KeyValuePair<string, number> = { key: "age", value: 30 };
const numberBooleanPair: KeyValuePair<number, boolean> = { key: 1, value: true };
Here:
<K, V>declares two type parameters:Kfor the key type andVfor the value type.This interface can be used to create type-safe key-value pairs of any types.
Example 3: Conditional Types
Let’s revisit the NonNullable type we discussed earlier:
type NonNullable<T> = T extends null | undefined ? never : T;
// Usage
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>;
let nullable: MaybeString = null;
// let nonNullable: DefinitelyString = null; // This would cause a type error
function printLength(str: NonNullable<string | null>) {
console.log(str.length); // TypeScript knows str is definitely a string
}
In this example:
NonNullable<T>removesnullandundefinedfrom a type.It uses a conditional type to check if
Textendsnull | undefined.If true, it returns
never(a type that can never occur), effectively removing that type.If false, it returns
Tunchanged.
This is particularly useful when you want to ensure a value is not null or undefined before working with it, preventing potential runtime errors.
Example 4: Generic Classes with Multiple Type Parameters
Let’s create a more complex data structure using multiple type parameters:
class Dictionary<K extends string | number | symbol, V> {
private items: Record<K, V> = {} as Record<K, V>;
set(key: K, value: V): void {
this.items[key] = value;
}
get(key: K): V | undefined {
return this.items[key];
}
remove(key: K): void {
delete this.items[key];
}
keys(): K[] {
return Object.keys(this.items) as K[];
}
}
// Usage
const userAges = new Dictionary<string, number>();
userAges.set("Alice", 30);
userAges.set("Bob", 25);
console.log(userAges.get("Alice")); // Output: 30
console.log(userAges.keys()); // Output: ["Alice", "Bob"]
const numberDict = new Dictionary<number, string>();
numberDict.set(1, "One");
numberDict.set(2, "Two");
console.log(numberDict.get(1)); // Output: "One"
Explanation:
This
Dictionaryclass uses two type parameters:Kfor keys andVfor values.K extends string | number | symbolconstrains the key type to valid object property types.We use
Record<K, V>to type our internal storage, ensuring type safety.The class provides type-safe methods for setting, getting, and removing key-value pairs.
We can create dictionaries with different key and value types while maintaining type safety.
Example 5: Advanced Generic Constraints with keyof
Let’s explore a more advanced use of generic constraints with the keyof operator:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface Person {
name: string;
age: number;
location: string;
}
const person: Person = {
name: "Alice",
age: 30,
location: "New York"
};
// Usage
console.log(getProperty(person, "name")); // Output: "Alice"
console.log(getProperty(person, "age")); // Output: 30
// This would cause a compile-time error:
// console.log(getProperty(person, "job"));
Explanation:
getPropertyis a generic function that takes two type parameters:Tfor the object type andKfor the key type.K extends keyof TconstrainsKto be only the keys ofT.This ensures that we can only access properties that actually exist on the object.
TypeScript knows the exact return type (
T[K]) based on the key we provide.Trying to access a non-existent property results in a compile-time error.
Example 6: Conditional Types with Generics
Let’s delve into a more complex example using conditional types with generics:
type Flatten<T> = T extends Array<infer U> ? U : T;
type Str = Flatten<string>; // string
type Num = Flatten<number>; // number
type StrArr = Flatten<string[]>; // string
type NumArr = Flatten<Array<number>>; // number
// More complex example
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type PromiseString = UnwrapPromise<Promise<string>>; // string
type JustNumber = UnwrapPromise<number>; // number
// Usage in a function
async function fetchData<T>(promise: Promise<T>): Promise<UnwrapPromise<T>> {
const result = await promise;
return result as UnwrapPromise<T>;
}
// Example usage
const stringPromise = Promise.resolve("Hello, TypeScript!");
fetchData(stringPromise).then(result => {
console.log(result.toUpperCase()); // TypeScript knows 'result' is a string
});
Explanation:
Flatten<T>is a conditional type that “flattens” an array type to its element type.If
Tis an array, it extracts the array element type usinginfer U. Otherwise, it returnsTunchanged.UnwrapPromise<T>similarly “unwraps” a Promise type to its resolved type.The
fetchDatafunction demonstrates how these conditional types can be used in practical scenarios.TypeScript can infer and propagate these complex type relationships, providing excellent type safety and developer experience.
Example 7: Advanced Generic Utility for API Response Handling
In this complex example, we’ll create a utility for handling API responses with strong typing, error handling, and data transformation. This example combines generics with mapped types, conditional types, and higher-order functions.
// Define possible API response statuses
type ApiStatus = 'success' | 'error' | 'loading';
// Generic interface for API responses
interface ApiResponse<T> {
status: ApiStatus;
data?: T;
error?: string;
}
// Utility type to extract success data type from ApiResponse
type SuccessData<T> = T extends ApiResponse<infer U> ? U : never;
// Generic type for data transformers
type DataTransformer<T, U> = (data: T) => U;
// Main utility function for handling API responses
function handleApiResponse<T, U = T>(
response: ApiResponse<T>,
onSuccess: (data: T) => void,
onError: (error: string) => void,
transform?: DataTransformer<T, U>
): ApiResponse<U> {
switch (response.status) {
case 'success':
if (response.data) {
const transformedData = transform ? transform(response.data) : response.data as unknown as U;
onSuccess(response.data);
return { status: 'success', data: transformedData };
}
return { status: 'error', error: 'Data is undefined' };
case 'error':
onError(response.error || 'Unknown error');
return { ...response, data: undefined } as ApiResponse<U>;
case 'loading':
return { ...response, data: undefined } as ApiResponse<U>;
}
}
// Example usage
// Define some sample data types
interface User {
id: number;
name: string;
email: string;
}
interface TransformedUser {
fullName: string;
contactInfo: string;
}
// Sample API responses
const successResponse: ApiResponse<User> = {
status: 'success',
data: { id: 1, name: 'John Doe', email: 'john@example.com' }
};
const errorResponse: ApiResponse<User> = {
status: 'error',
error: 'User not found'
};
// Define a transformer function
const userTransformer: DataTransformer<User, TransformedUser> = (user) => ({
fullName: user.name,
contactInfo: `${user.email} (ID: ${user.id})`
});
// Handle successful response
const successResult = handleApiResponse<User, TransformedUser>(
successResponse,
(data) => console.log('Success:', data),
(error) => console.error('Error:', error),
userTransformer
);
console.log('Transformed success result:', successResult);
// Handle error response
const errorResult = handleApiResponse<User>(
errorResponse,
(data) => console.log('Success:', data),
(error) => console.error('Error:', error)
);
console.log('Error result:', errorResult);
// Type inference in action
type InferredSuccessData = SuccessData<typeof successResult>;
// InferredSuccessData is now TransformedUser
Explanation:
We define a generic
ApiResponse<T>interface to represent API responses with different data types.SuccessData<T>is a conditional type that extracts the data type from anApiResponse.DataTransformer<T, U>is a generic type for functions that transform data from one type to another.The
handleApiResponsefunction is the core utility:It takes an
ApiResponse<T>, success and error callbacks, and an optional data transformer.It uses function overloading to handle cases with and without a transformer.
The function returns an
ApiResponse<U>, whereUis either the transformed type or the original typeT.
We demonstrate the utility with sample
UserandTransformedUserinterfaces.The example shows how to handle both success and error responses, with and without data transformation.
Type inference is demonstrated with the
InferredSuccessDatatype, which correctly infers the transformed data type.
Conclusion
Generics in TypeScript stand as a cornerstone feature, empowering developers to craft code that is simultaneously flexible, reusable, and type-safe. Throughout this post, we’ve explored how generics enable the creation of functions, classes, and interfaces that work seamlessly across multiple types while maintaining robust type checking. From simple utility functions to complex data structures and API handlers, generics prove their versatility and power in a wide array of programming scenarios.
As you continue your TypeScript journey, embrace generics as an essential tool in your development arsenal. They offer a unique balance between specificity and broad applicability, allowing your code to be both precise in its type safety and widely adaptable. With practice and exploration, you’ll find generics indispensable for solving complex programming challenges and building scalable, maintainable TypeScript applications.
For more about generics visit typescript’s docs.
Also Read: Integrate Amplitude with React Native




