Skip to Content
Course content

62: Variadic Tuple Types

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

You've likely dealt with generics and arrays in TypeScript, but there's a specific point where standard arrays stop being useful: when the order and number of elements matter. That's where tuples come in. But for a long time, tuples were rigid. If you wanted to create a function that manipulated the arguments of another function—like adding a logger or a session ID to the front of the argument list—you usually ended up falling back to any[] and losing all your type safety.

I see this all the time in middleware or wrapper functions. Let's look at a piece of code that looks correct at a glance but completely fails to provide the safety we expect from TypeScript.

The Type Erosion Problem

type Handler = (id: number, name: string) => void;

const saveUser: Handler = (id, name) => {
  console.log(`Saving user ${name} with ID ${id}`);
};

// We want a wrapper that adds a "traceId" to every call
function wrapWithTrace(fn: (...args: any[]) => void) {
  return (...args: any[]) => {
    const traceId = Math.random().toString();
    console.log(`Trace: ${traceId}`);
    // This is where the danger is: we're just spreading 'any'
    fn(...args); 
  };
}

const wrappedSave = wrapWithTrace(saveUser);

// BUG: TypeScript doesn't complain about this, but it will crash or behave 
// unexpectedly at runtime because the arguments are wrong.
wrappedSave("wrong", 123, "too many arguments"); 

The problem here is "type erosion." By using any[], we've told TypeScript to stop checking the arguments. The wrappedSave function now accepts literally anything. Even worse, if we tried to use a generic T extends any[] without variadic tuple types, we'd struggle to define a return type that precisely describes the new argument list (which should be the original list plus the trace ID).

Preserving Structure with Variadic Tuple Types

Variadic Tuple Types allow us to use the spread operator ... inside a tuple type definition. This lets us say: "This tuple starts with X, and then contains whatever was in that other tuple."

Here is how I would refactor that wrapper to ensure the compiler knows exactly what the wrapped function requires, while still allowing the wrapper to add its own arguments.

type Handler = (id: number, name: string) => void;

const saveUser: Handler = (id, name) => {
  console.log(`Saving user ${name} with ID ${id}`);
};

// We use T to capture the exact tuple of arguments the original function expects
function wrapWithTrace<T extends any[]>(fn: (...args: T) => void) {
  // We define the new argument list as a tuple: 
  // The first element is a string (our traceId), followed by the original tuple T
  return (...args: [string, ...T]): void => {
    const [traceId, ...rest] = args;
    console.log(`Trace: ${traceId}`);
    
    // Because 'rest' is typed as T, this call is perfectly safe
    fn(...rest);
  };
}

const wrappedSave = wrapWithTrace(saveUser);

// Now, TypeScript correctly identifies that the first arg must be a string,
// followed by the Handler's arguments: (string, number, string)
wrappedSave("req-123", 1, "Alice"); // Works!

// This will now throw a compile-time error: 
// Argument of type 'string' is not assignable to parameter of type 'number'.
wrappedSave("req-123", "wrong", "Alice"); 

What happened here is that [string, ...T] created a new, dynamic tuple. TypeScript doesn't just see "an array of things"; it sees a sequence where the first element is fixed as a string and the subsequent elements are mapped exactly to the signature of saveUser. This is the "magic" of variadic tuple types—they let us perform algebra on the shapes of our functions.

I should mention that this isn't just for adding elements to the front. You can use ...T to slice tuples, merge two different tuples together, or create "rest" parameters that maintain their specific types instead of collapsing into a general array. It's the difference between telling TypeScript "there are some numbers here" and "there is exactly one string, followed by exactly two numbers, followed by whatever else was in the original list."




📋 Practical Task

Build a Type-Safe Command Dispatcher

You are building a command system where different commands take different arguments. You need to create a dispatch function that acts as a gateway. The dispatch function should take the command name as the first argument, and then the exact arguments required by that specific command.

Your Task:

  1. Create a CommandMap interface where keys are command names (e.g., "move", "jump") and values are the functions that implement those commands.
  2. Implement a dispatch function using variadic tuple types. It should take the command name and a spread of arguments.
  3. Ensure that if you call dispatch("move", 10, 20) and the "move" command expects two numbers, it passes. If you pass a string instead, TypeScript should throw an error.

Hint: You'll need to use a generic that extends the keys of your CommandMap and then use the spread operator to link the arguments of the chosen command to the arguments of the dispatch function.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.