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.

These final exercises dive into advanced TypeScript features: currying, function overloads, mapped types, and type-safe object manipulation.

Exercise 14: Curried Functions with Overloads

Scenario

You’re building a functional programming library with curried functions. Each function should support multiple calling patterns:
  • No arguments: returns itself
  • Partial arguments: returns a function waiting for remaining arguments
  • All arguments: executes and returns the result

The Problem

export function map(mapper, input) {
    if (arguments.length === 0) {
        return map;
    }
    if (arguments.length === 1) {
        return function subFunction(subInput) {
            if (arguments.length === 0) {
                return subFunction;
            }
            return subInput.map(mapper);
        };
    }
    return input.map(mapper);
}

// No types - everything is 'any'
These functions use arguments.length checks, making typing complex. You need function overloads to capture all possible call signatures.

Key Concepts

Function Overloads

Multiple type signatures for different argument combinations

Currying

Transforming a function with multiple arguments into a sequence of functions

Recursive Types

Types that reference themselves for nested function returns

Understanding Currying

Currying transforms a function like this:
// Regular function
function add(a: number, b: number): number {
    return a + b;
}
add(2, 3);  // 5

// Curried function
function addCurried(a: number): (b: number) => number {
    return (b: number) => a + b;
}
addCurried(2)(3);  // 5

const add2 = addCurried(2);
add2(3);  // 5
add2(5);  // 7
These exercises add a twist: calling with no arguments returns the function itself.

Solution: Map Function

export function map(mapper, input) {
    if (arguments.length === 0) return map;
    if (arguments.length === 1) {
        return function subFunction(subInput) {
            if (arguments.length === 0) return subFunction;
            return subInput.map(mapper);
        };
    }
    return input.map(mapper);
}

Breaking Down the Types

1

MapperFunc - Partial Application

interface MapperFunc<I, O> {
    (): MapperFunc<I, O>;  // Called with no args - returns itself
    (input: I[]): O[];      // Called with input - executes map
}
This represents the function returned when you call map with just a mapper.
2

MapFunc - Full Interface

interface MapFunc {
    (): MapFunc;  // No args - returns itself
    <I, O>(mapper: (item: I) => O): MapperFunc<I, O>;  // Mapper only
    <I, O>(mapper: (item: I) => O, input: I[]): O[];   // Both args
}
Defines all possible ways to call map.
3

Generic Type Parameters

  • I: Input array element type
  • O: Output array element type
These are inferred from usage:
map((x: number) => x.toString(), [1, 2, 3])
//   ^^^^^^ I=number  ^^^^^^^^^^^ O=string

Usage Examples

const numbers = [1, 2, 3, 4, 5];

// All arguments at once
const strings = map((x: number) => x.toString(), numbers);
// strings: string[]

// Curried - create reusable mapper
const double = map((x: number) => x * 2);
const doubled = double(numbers);  // [2, 4, 6, 8, 10]
const moreDoubled = double([10, 20]);  // [20, 40]

// Called with no args - returns itself
const mapAgain = map();
mapAgain === map;  // true

Other Curried Functions

interface FiltererFunc<I> {
    (): FiltererFunc<I>;
    (input: I[]): I[];
}

interface FilterFunc {
    (): FilterFunc;
    <I>(filterer: (item: I) => boolean): FiltererFunc<I>;
    <I>(filterer: (item: I) => boolean, input: I[]): I[];
}

export const filter: FilterFunc = /* implementation */;

// Usage
const evens = filter((x: number) => x % 2 === 0);
evens([1, 2, 3, 4]);  // [2, 4]
interface ArithmeticArgFunc {
    (): ArithmeticArgFunc;
    (b: number): number;
}

interface ArithmeticFunc {
    (): ArithmeticFunc;
    (a: number): ArithmeticArgFunc;
    (a: number, b: number): number;
}

export const add: ArithmeticFunc = /* implementation */;

// Usage
add(2, 3);        // 5
add(2)(3);        // 5
const add2 = add(2);
add2(3);          // 5
add2()()()(10);   // 12
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;
    // ... more overloads for additional functions
}

export const pipe: PipeFunc = /* implementation */;

// Usage
const transform = pipe(
    (x: number) => x * 2,
    (x: number) => x.toString(),
    (x: string) => x + '!'
);
transform(5);  // '10!'

Exercise 15: Type-Safe Object Manipulation

Scenario

Build an ObjectManipulator class that lets you safely get, set, and delete properties while tracking type changes.

The Problem

export class ObjectManipulator {
    constructor(protected obj) {}  // No type
    
    public set(key, value) {  // No types
        return new ObjectManipulator({...this.obj, [key]: value});
    }
    
    public get(key) {  // Returns 'any'
        return this.obj[key];
    }
    
    public delete(key) {  // No type tracking
        const newObj = {...this.obj};
        delete newObj[key];
        return new ObjectManipulator(newObj);
    }
    
    public getObject() {  // Returns 'any'
        return this.obj;
    }
}
The class has no generic type parameter, so all operations lose type information.

Key Concepts

Generic Classes

Classes parameterized by types

Mapped Types

Creating new types by transforming properties

Type Evolution

Tracking how types change through operations

Solution

export class ObjectManipulator {
    constructor(protected obj) {}
    
    public set(key, value) {
        return new ObjectManipulator({...this.obj, [key]: value});
    }
    
    public get(key) {
        return this.obj[key];
    }
    
    public delete(key) {
        const newObj = {...this.obj};
        delete newObj[key];
        return new ObjectManipulator(newObj);
    }
    
    public getObject() {
        return this.obj;
    }
}

Understanding Type Evolution

1

Start with Initial Type

const manipulator = new ObjectManipulator({ name: 'John', age: 30 });
// Type: ObjectManipulator<{ name: string; age: number }>
2

Set Adds Property

const withRole = manipulator.set('role', 'admin');
// Type: ObjectManipulator<{ name: string; age: number; role: string }>
3

Delete Removes Property

const withoutAge = withRole.delete('age');
// Type: ObjectManipulator<{ name: string; role: string }>
4

Get Returns Correct Type

const name = withoutAge.get('name');  // string
const role = withoutAge.get('role');  // string
// withoutAge.get('age');  // ❌ Error: 'age' doesn't exist

Key Type Definitions

type ObjectWithNewProp<T, K extends string, V> = T & {[NK in K]: V};
This creates a new type by adding a property:
  • T: The existing object type
  • K: The new property name (must be a string literal)
  • V: The new property’s value type
  • {[NK in K]: V}: Mapped type that creates { [key]: V }
  • T & ...: Intersection combines the types
Example:
type User = { name: string };
type UserWithAge = ObjectWithNewProp<User, 'age', number>;
// Result: { name: string; age: number }
Each method has its own generic parameters:
// Class has generic T
export class ObjectManipulator<T> {
    // Method adds generic K for the key
    public get<K extends keyof T>(key: K): T[K] {
        return this.obj[key];
    }
}
The K extends keyof T constraint ensures you can only get keys that exist on T.

Usage Examples

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

const user: User = { name: 'Alice', age: 25 };
const manipulator = new ObjectManipulator(user);

// Get existing properties
const name = manipulator.get('name');  // string
const age = manipulator.get('age');    // number

// Set new property
const withEmail = manipulator.set('email', 'alice@example.com');
const email = withEmail.get('email');  // string

// Delete property
const withoutAge = withEmail.delete('age');
// withoutAge.get('age');  // ❌ Error: Property 'age' does not exist

// Get final object
const final = withoutAge.getObject();
// Type: { name: string; email: string }

// Chain operations
const result = new ObjectManipulator({ x: 1 })
    .set('y', 2)
    .set('z', 3)
    .delete('x')
    .getObject();
// Type: { y: number; z: number }

Advanced Patterns

class UserBuilder extends ObjectManipulator<{}> {
    constructor() {
        super({});
    }
    
    withName(name: string) {
        return this.set('name', name);
    }
    
    withAge(age: number) {
        return this.set('age', age);
    }
    
    build() {
        return this.getObject();
    }
}

const user = new UserBuilder()
    .withName('Bob')
    .withAge(30)
    .build();
// Type: { name: string; age: number }

Why These Patterns Matter

Curried functions enable:
  • Partial application
  • Function composition
  • Point-free style
  • Reusable transformations
Tracking type changes through operations prevents:
  • Accessing deleted properties
  • Type mismatches
  • Runtime errors
  • Invalid transformations
Creating new objects instead of mutating:
  • Prevents bugs from shared mutable state
  • Enables time-travel debugging
  • Makes code predictable
  • Supports concurrent operations

What You Learned

Define complex type signatures for functions with multiple calling patterns
Create classes that track and transform types through operations
Generate new types by transforming existing type properties
Use TypeScript’s type system to enforce invariants at compile time
Congratulations! You’ve completed all TypeScript exercises, progressing from basic interfaces to advanced type manipulation. You now have the skills to build type-safe, maintainable TypeScript applications.

Additional Resources