TypeScript Generic Types Tutorial
ChatGPT & Benji AsperheimFri Jan 10th, 2025

TypeScript Generics Overview

Let’s break down each piece to clarify what’s happening with T, extends, and why these constructs are often used, and what generic type parameter are in TypeScript.

DISCLAIMER: The majority of this article was generated from ChatGPT prompts, and curated/edited by a human being.

What Does ‘T’ Mean?

What exactly does the T in something like <T extends { [key: string]: any }> do or mean? This T is a generic type parameter. Generics allow you to create reusable, type-safe code that works with different types while preserving type information.

Example:

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

identity(42); // T is inferred as number
identity("hello"); // T is inferred as string

What is the ‘extends’ keyword for?

The extends keyword in a generic constraint means that T must satisfy the condition of being assignable to the specified type, which in this case is { [key: string]: any }.

This prevents T from being a primitive type like number, string, or boolean, which are not assignable to an object type.

Example:

function logObjectKeys<T extends { [key: string]: any }>(obj: T) {
  console.log(Object.keys(obj));
}

logObjectKeys({ a: 1, b: 2 }); // Works
logObjectKeys(42); // Error: number is not assignable to { [key: string]: any }

What does ‘Record’ mean exactly?

The Record type indicates that the variable should be a kind of object literal (see this StackOverflow post). Also, Record<string, any> is a utility type provided by TypeScript. It is equivalent to { [key: string]: any }, but more succinct and standardized.

It means an object with keys of type string and values of any type:

type MyObject = Record<string, any>;

const example: MyObject = { a: 1, b: "hello" }; // Works

The key differences between <T extends { [key: string]: any }> and Record<string, any> are:

Aspect<T extends { [key: string]: any }>Record<string, any>
GenericYes, defines a generic type placeholder T.No, Record<string, any> is a specific type.
FlexibilityYou can add additional constraints or combine with other types.Fixed type definition.
Use CaseUsed when defining generic functions or classes.Used for a concrete type definition.

Why do we see ‘T extends …’ in function parameters?

The main reasons are:

  1. Type Constraints: To ensure the function parameter T satisfies certain requirements. For example, ensuring T is an object type or has specific keys.

  2. Type Inference: When T is a generic type, it allows TypeScript to infer the type based on the function’s usage, which makes the code more flexible and reusable.

Example:

function getValue<T extends { [key: string]: any }>(obj: T, key: string): any {
  return obj[key];
}

const user = { id: 1, name: "John" };
getValue(user, "name"); // Works
getValue(42, "id"); // Error: Argument of type 'number' is not assignable to ...

Summary of Typescript Generics

To summarize what generics in TypeScript do:

Examples of Generics and Record

Generics like T can work with Record<string, any> just as they work with { [key: string]: any }. In fact, using Record<string, any> is often cleaner because it’s a standard utility type provided by TypeScript.

TypeScript Examples Using Extends

Basic Example with <T extends { [key: string]: any }>:

function printKeys<T extends { [key: string]: any }>(obj: T): void {
  console.log(Object.keys(obj));
}

printKeys({ name: "Alice", age: 30 }); // Logs: ['name', 'age']
printKeys({ id: 123, active: true }); // Logs: ['id', 'active']
// printKeys(42); // Error: Type 'number' is not assignable to '{ [key: string]: any }'

Here, T is constrained to be object-like (an object with string keys and any values). If you pass a non-object (like a number), TypeScript gives an error.

The same behavior can be implemented using Record<string, any>:

function printValues<T extends Record<string, any>>(obj: T): void {
  console.log(Object.values(obj));
}

printValues({ firstName: "Bob", lastName: "Smith" }); // Logs: ['Bob', 'Smith']
printValues({ x: 1, y: 2 }); // Logs: [1, 2]
// printValues(42); // Error: Type 'number' is not assignable to 'Record<string, any>'

This is functionally equivalent to the first example, but uses Record<string, any> for the constraint.

Adding Specificity to Generics

You can further constrain T by adding more specific requirements. Here’s an example that require specific keys:

function getProperty<T extends Record<string, any>>(obj: T, key: keyof T): any {
  return obj[key];
}

const person = { name: "Alice", age: 25 };

console.log(getProperty(person, "name")); // Logs: Alice
console.log(getProperty(person, "age")); // Logs: 25
// console.log(getProperty(person, "height")); // Error: Argument of type '"height"' is not assignable to 'keyof T'

In the above example, keyof T ensures the key parameter is valid for the given object obj.

Combining Multiple Constraints

You can use extends to create more complex constraints. For example, requiring T to have specific properties in addition to being a Record:

function hasRequiredFields<T extends Record<string, any> & { id: string; isActive: boolean }>(obj: T): boolean {
  return obj.id !== "" && obj.isActive;
}

const user = { id: "123", isActive: true, name: "Alice" };
const product = { id: "", isActive: false, price: 100 };

console.log(hasRequiredFields(user)); // Logs: true
console.log(hasRequiredFields(product)); // Logs: false
// hasRequiredFields(42); // Error: Type 'number' is not assignable to required type

The above example shows how T must be an object with string keys (Record<string, any>), and must also have id and isActive as properties.

Using with Default Type Values

You can set a default type for a generic, which can also use Record<string, any>:

function mergeObjects<T extends Record<string, any> = {}>(obj1: T, obj2: T): T {
  return { ...obj1, ...obj2 };
}

const merged = mergeObjects({ a: 1 }, { b: 2 });
console.log(merged); // Logs: { a: 1, b: 2 }

const defaultMerged = mergeObjects({}, { name: "Bob" });
console.log(defaultMerged); // Logs: { name: "Bob" }

This example shows how the default type for T falls back to an empty object ({}) if none is provided.

Dynamic Typing in Real-Life Use Cases

You might use <T extends Record<string, any>> when parsing API responses, where you want to ensure flexibility for unknown keys but still have type safety:

function parseApiResponse<T extends Record<string, any>>(response: T): T {
  console.log("Keys:", Object.keys(response));
  console.log("Values:", Object.values(response));
  return response;
}

const apiResponse = { status: "success", data: { id: 1, name: "Alice" } };
parseApiResponse(apiResponse);

Dynamic Form Validation

You could validate dynamic forms where fields are not predefined but need to follow a certain structure:

function validateForm<T extends Record<string, string>>(formData: T): boolean {
  return Object.values(formData).every((value) => value.trim() !== "");
}

const form = { username: "user1", email: "user@example.com" };
console.log(validateForm(form)); // Logs: true

const invalidForm = { username: "", email: "user@example.com" };
console.log(validateForm(invalidForm)); // Logs: false

Conclusion

Here’s a final summary of the T extends Record<string, any> benefits:

  1. Ensures the parameter is object-like: Prevents passing primitive types like number or boolean.
  2. Provides flexibility: You can combine it with additional constraints to create more specific types.
  3. Reusable and Clean: By using Record<string, any>, you avoid boilerplate definitions like { [key: string]: any }.
  4. Type Safety: Allows TypeScript to infer types while maintaining checks like keyof T.