Skip to Content
Course content

225: Whiteboard Practice: Implementing a Type-Safe Curry Function

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Currying is one of those functional programming concepts that sounds intimidating until you actually see it in action. At its core, it's just transforming a function that takes multiple arguments into a series of functions that each take a single argument. In JavaScript, it's trivial. In TypeScript, making it type-safe—meaning the compiler knows exactly which argument is expected at each step—is where the real challenge lies.

Let's pretend we're at a whiteboard. I want to build a utility for a pricing engine. I have a function that calculates a final price based on a tax rate, a discount, and the base price. It looks like this:

function calculateTotal(tax: number, discount: number, price: number) {
  return price * (1 - discount) * (1 + tax);
}

Now, in a real app, I might know the tax rate and discount for a specific region, but not the price of the item yet. I want to "preset" those first two arguments. That's where a curry function comes in.

The "Quick and Dirty" Attempt

If I were writing this in plain JS, I'd just write a wrapper. Let's try that in TS and see where it breaks.

function curry(fn: Function) {
  return function curried(...args: any[]) {
    if (args.length >= fn.length) {
      return fn(...args);
    } else {
      return (...nextArgs: any[]) => curried(...args, ...nextArgs);
    }
  };
}

const curriedTotal = curry(calculateTotal);
const applyVAT = curriedTotal(0.20); // Tax is 20%
const applyVATAndDiscount = applyVAT(0.10); // Discount is 10%
const finalPrice = applyVATAndDiscount(100); // Price is 100

This works at runtime, but look at the types. If I hover over finalPrice, it's any. If I accidentally pass a string to applyVAT, TypeScript won't complain. We've essentially thrown away the type system to get the functionality. That's a fail in my book.

Peeling the Parameter Onion

To fix this, we need a type that can "peel off" arguments from the original function one by one. We can use conditional types and the infer keyword to look at the function's parameters as a tuple.

I'll try to define a type Curried that checks: "Is there a first argument? If so, return a function that takes that argument and returns another Curried version of the remaining arguments."

type Curried<Args extends any[], Return> = 
  Args extends [infer First, ...infer Rest] 
    ? (arg: First) => Curried<Rest, Return> 
    : Return;

I like this logic. It's recursive. It keeps slicing the Args tuple until there's nothing left, at which point it finally returns the Return type. Now let's try to plug this into our curry function. This is where it gets a bit fiddly with generics.

Connecting the Logic to the Implementation

I need to capture the parameters of the input function F. TypeScript has a built-in utility called Parameters<T> that does exactly that. Let's see if we can marry the recursive type with the actual function implementation.

function curry<F extends (...args: any[]) => any>(fn: F): Curried<Parameters<F>, ReturnType<F>> {
  return function curried(...args: any[]) {
    if (args.length >= fn.length) {
      return fn(...args) as any;
    } else {
      return (...nextArgs: any[]) => curried(...args, ...nextArgs) as any;
    }
  } as any;
}

You'll notice I used a few as any casts inside the implementation. I'll be honest with you: when you're writing highly generic recursive types in TypeScript, the compiler often struggles to prove that the recursive implementation perfectly matches the recursive type. As long as the public API (the function signature) is type-safe, a few internal casts are usually a necessary evil in utility libraries.

Testing the Safety Net

Now let's see if the compiler actually helps us. If I try to use our curriedTotal now:

const curriedTotal = curry(calculateTotal);
const applyVAT = curriedTotal(0.20); // Correct: expects number
const error = applyVAT("high"); // TypeScript Error: Argument of type 'string' is not assignable to parameter of type 'number'.

There it is. The compiler knows that the second call to applyVAT must be a number because it's looking at the second element of the calculateTotal parameter list. We've successfully turned a runtime convenience into a compile-time guarantee.




📋 Practical Task

Exercise: Build a Type-Safe Curried Logger

In this exercise, you will implement a curried logging function. You are provided with a base function logMessage(level: string, component: string, message: string). Your goal is to use the curry implementation from the lesson to create a specialized logger.

Requirements:

  • Implement the Curried type and the curry function as explored in the lesson.
  • Create a curried version of logMessage.
  • Create a specialized function called logNetworkError by pre-filling the level as "ERROR" and the component as "NetworkModule".
  • Verify that calling logNetworkError("Timeout occurred") works and is type-safe, while calling logNetworkError(404) produces a TypeScript error.
// Base function to curry
function logMessage(level: string, component: string, message: string) {
  console.log(`[${level}] [${component}]: ${message}`);
}

// Your implementation of Curried type and curry function goes here...

// Your specialized logger implementation goes here...
Rating
0 0

There are no comments for now.

to be the first to leave a comment.