Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/typescript-exercises/typescript-exercises/llms.txt

Use this file to discover all available pages before exploring further.

What are Generics?

Generics allow you to write reusable, type-safe code that works with multiple types while preserving type information. They’re like function parameters, but for types.

Basic Generics

From Exercise 7, here’s a simple generic function:
function swap<T1, T2>(v1: T1, v2: T2): [T2, T1] {
    return [v2, v1];
}

// TypeScript infers the types
const [secondUser, firstAdmin] = swap(admins[0], users[1]);
// secondUser is User
// firstAdmin is Admin

const [stringValue, numericValue] = swap(123, 'Hello World');
// stringValue is string
// numericValue is number
Without generics, you’d need to use any (losing type safety) or create separate functions for each type combination:
// Without generics - loses type safety
function swap(v1: any, v2: any): [any, any] {
    return [v2, v1];
}

// Or create multiple functions - not scalable
function swapUserAdmin(v1: User, v2: Admin): [Admin, User] { ... }
function swapAdminUser(v1: Admin, v2: User): [User, Admin] { ... }
// ... many more combinations

Generic Constraints

Constraints limit what types can be used with a generic:

Basic Constraints

// Constrain to types with a 'length' property
function logLength<T extends { length: number }>(item: T): void {
    console.log(item.length);
}

logLength('hello');        // ✓ string has length
logLength([1, 2, 3]);      // ✓ array has length
logLength({ length: 5 });  // ✓ object has length
// logLength(123);         // ✗ number doesn't have length

Interface Constraints

interface Person {
    name: string;
    age: number;
}

// Only accept types that extend Person
function greet<T extends Person>(person: T): string {
    return `Hello, ${person.name}!`;
}

interface User extends Person {
    occupation: string;
}

const user: User = { name: 'John', age: 30, occupation: 'Developer' };
greet(user);  // ✓ User extends Person

// greet({ name: 'John' });  // ✗ Missing 'age' property

Multiple Constraints

interface Nameable {
    name: string;
}

interface Ageable {
    age: number;
}

// T must satisfy both constraints
function describe<T extends Nameable & Ageable>(person: T): string {
    return `${person.name} is ${person.age} years old`;
}
Constraints help you:
  • Access specific properties safely
  • Ensure types have required structure
  • Create more specific generic functions
  • Provide better IDE autocomplete

Generic Classes

From Exercise 15, here’s a generic class:
type ObjectWithNewProp<T, K extends string, V> = T & { [NK in K]: V };

class ObjectManipulator<T> {
    constructor(protected obj: T) {}

    public set<K extends string, V>(
        key: K,
        value: V
    ): ObjectManipulator<ObjectWithNewProp<T, K, V>> {
        return new ObjectManipulator({ ...this.obj, [key]: value } as ObjectWithNewProp<T, K, V>);
    }

    public get<K extends keyof T>(key: K): T[K] {
        return this.obj[key];
    }

    public delete<K extends keyof T>(key: K): ObjectManipulator<Omit<T, K>> {
        const newObj = { ...this.obj };
        delete newObj[key];
        return new ObjectManipulator(newObj);
    }

    public getObject(): T {
        return this.obj;
    }
}

// Usage - types are tracked through transformations
const obj = new ObjectManipulator({});
const withName = obj.set('name', 'John');         // ObjectManipulator<{ name: string }>
const withAge = withName.set('age', 30);          // ObjectManipulator<{ name: string; age: number }>
const result = withAge.getObject();               // { name: string; age: number }
Each method returns a new type that reflects the transformation:
  • set() adds a property to the type
  • delete() removes a property using Omit
  • get() uses keyof to ensure the key exists
TypeScript tracks these changes through the chain of operations, maintaining perfect type safety.

Generic Functions with Inference

From Exercise 10, here’s advanced type inference:
type ApiResponse<T> = 
    | { status: 'success'; data: T }
    | { status: 'error'; error: string };

type CallbackBasedAsyncFunction<T> = (
    callback: (response: ApiResponse<T>) => void
) => void;

type PromiseBasedAsyncFunction<T> = () => Promise<T>;

// TypeScript infers T from the input function
function promisify<T>(
    fn: CallbackBasedAsyncFunction<T>
): PromiseBasedAsyncFunction<T> {
    return () => new Promise<T>((resolve, reject) => {
        fn((response) => {
            if (response.status === 'success') {
                resolve(response.data);
            } else {
                reject(new Error(response.error));
            }
        });
    });
}

// Usage - type is inferred automatically
const oldRequestUsers = (callback: (response: ApiResponse<User[]>) => void) => {
    // ...
};

const newRequestUsers = promisify(oldRequestUsers);
// Type is inferred as: () => Promise<User[]>

Type Inference from Object Structure

type SourceObject<T> = { [K in keyof T]: CallbackBasedAsyncFunction<T[K]> };
type PromisifiedObject<T> = { [K in keyof T]: PromiseBasedAsyncFunction<T[K]> };

function promisifyAll<T extends { [key: string]: any }>(
    obj: SourceObject<T>
): PromisifiedObject<T> {
    const result: Partial<PromisifiedObject<T>> = {};
    for (const key of Object.keys(obj) as (keyof T)[]) {
        result[key] = promisify(obj[key]);
    }
    return result as PromisifiedObject<T>;
}

// Usage - entire object structure is inferred
const oldApi = {
    requestUsers: (callback: (response: ApiResponse<User[]>) => void) => {},
    requestAdmins: (callback: (response: ApiResponse<Admin[]>) => void) => {},
};

const api = promisifyAll(oldApi);
// api.requestUsers is () => Promise<User[]>
// api.requestAdmins is () => Promise<Admin[]>
Type inference is powerful but can be complex. Add explicit type annotations when:
  • The inferred type is too broad
  • You want to catch errors earlier
  • The code is hard to understand
  • You’re creating a public API

Mapped Types

Mapped types transform each property in a type:
// Make all properties optional
type Partial<T> = {
    [P in keyof T]?: T[P];
};

// Make all properties readonly
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

// Pick specific properties
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

Custom Mapped Types

// Add 'get' and 'set' methods for each property
type Accessors<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
} & {
    [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

interface User {
    name: string;
    age: number;
}

type UserAccessors = Accessors<User>;
// {
//     getName: () => string;
//     setName: (value: string) => void;
//     getAge: () => number;
//     setAge: (value: number) => void;
// }

Key Remapping

// Remove properties with specific types
type RemoveKind<T> = {
    [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Mixed {
    name: string;
    age: number;
    email: string;
}

type StringPropsOnly = RemoveKind<Mixed>;
// {
//     name: string;
//     email: string;
// }

Conditional Types in Generics

Conditional types allow logic at the type level:
// Extract array element type
type Unpack<T> = T extends Array<infer U> ? U : T;

type StringArray = Unpack<string[]>;  // string
type NumberType = Unpack<number>;     // number

// From Exercise 10 - Extract data from API response
type ExtractData<T> = T extends { data: infer D } ? D : never;

type ResponseData = ExtractData<{ status: 'success'; data: User[] }>;  // User[]
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;   // true
type B = IsString<number>;   // false

Generic Constraints with keyof

The keyof operator creates a union of an object’s keys:
interface User {
    name: string;
    age: number;
    occupation: string;
}

type UserKeys = keyof User;  // 'name' | 'age' | 'occupation'

// Access object properties safely
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user: User = { name: 'John', age: 30, occupation: 'Developer' };
const name = getProperty(user, 'name');        // string
const age = getProperty(user, 'age');          // number
// const invalid = getProperty(user, 'email'); // ✗ Error: 'email' not in User
From Exercise 14, here’s a practical pattern:
interface PropFunc {
    (): PropFunc;
    <K extends string>(propName: K): PropNameFunc<K>;
    <O, K extends keyof O>(propName: K, obj: O): O[K];
}

const prop: PropFunc = /* ... */;

// Curry-style property access
const getName = prop('name');
const userName = getName({ name: 'John', age: 30 });  // string

Variadic Tuple Types

Handle variable-length tuples with generics:
// From Exercise 14 - pipe function with type checking
type F<A extends unknown[], R> = (...args: A) => R;
type TR<I, O> = (arg: I) => O;

interface PipeFunc {
    (): PipeFunc;
    <A1 extends unknown[], R1>(f: F<A1, R1>): (...args: A1) => R1;
    <A1 extends unknown[], R1, R2>(
        f: F<A1, R1>,
        tr1: TR<R1, R2>
    ): (...args: A1) => R2;
    <A1 extends unknown[], R1, R2, R3>(
        f: F<A1, R1>,
        tr1: TR<R1, R2>,
        tr2: TR<R2, R3>
    ): (...args: A1) => R3;
}

// Usage - types are checked through the chain!
const transform = pipe(
    (x: number) => x * 2,           // number => number
    (x: number) => x.toString(),    // number => string
    (x: string) => x.length         // string => number
);

const result = transform(5);  // number (10 => "10" => 2)

Best Practices

Use short, descriptive names:
// Good
function map<T, U>(array: T[], fn: (item: T) => U): U[] { ... }

// Less clear
function map<InputType, OutputType>(
    array: InputType[],
    fn: (item: InputType) => OutputType
): OutputType[] { ... }

// Convention: T, U, V for generic types
// K for keys, V for values
// E for element types
// R for return types
Add constraints when you need to access properties or methods:
// Good - constraint allows accessing name
function greet<T extends { name: string }>(person: T) {
    return `Hello, ${person.name}`;
}

// Bad - can't access name without constraint
function greet<T>(person: T) {
    return `Hello, ${person.name}`;  // ✗ Error
}
Let TypeScript infer types instead of explicitly specifying them:
function swap<T1, T2>(v1: T1, v2: T2): [T2, T1] {
    return [v2, v1];
}

// Good - inferred
const result = swap(1, 'hello');

// Unnecessary - explicit
const result = swap<number, string>(1, 'hello');
Provide default types for frequently used generics:
interface ApiResponse<T = unknown> {
    status: number;
    data: T;
}

// Can use without specifying T
const response: ApiResponse = { status: 200, data: {} };

// Or specify T when needed
const userResponse: ApiResponse<User> = { status: 200, data: user };

Utility Types

See how generics power built-in utility types

Conditional Types

Master conditional logic with generics

Exercise 7

Practice basic generics with a swap function

Exercise 10

Build a promisify function with advanced generics

Further Reading