Skip to Content
Course content

227: Call Signatures and Construct Signatures

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

A lot of developers I mentor hit a wall when they realize they can't describe a function's behavior using just the standard arrow syntax (arg: string) => void. The most common misconception I see is the belief that function type aliases and call signatures are exactly the same thing. You'll often hear, "Why do I need this weird interface syntax when I can just use a type alias with an arrow?"

Here is why that thinking fails: arrow types are great for simple functions, but they are completely incapable of describing function overloads. If you try to define a type that can be called in two different ways using only arrow syntax, you'll find yourself fighting the compiler or resorting to messy union types that lose type safety at the call site.

Arrow Types Are Enough For Every Function Definition

// The "Wrong" way: Trying to handle multiple input types with a union
type Logger = (message: string | number) => void;

const log: Logger = (msg) => {
  // Inside here, 'msg' is string | number. 
  // I have to manually narrow the type every single time.
  if (typeof msg === 'string') {
    console.log("Text: " + msg);
  } else {
    console.log("Number: " + msg);
  }
};

This works for the implementation, but it's not "overloading." It's just a function that accepts a union. If you want the type system to know that calling it with a string returns a string and calling it with a number returns a number, the arrow syntax simply cannot do it. It's too limited.

Using Call Signatures for Overloads and Flexibility

This is where Call Signatures come in. By defining the function inside an interface or a type literal without giving it a name, you can list multiple signatures for the same function. I think of this as providing a "menu" of ways the function can be invoked.

interface SmartLogger {
  // These are Call Signatures
  (message: string): void;
  (message: number, precision: number): void;
}

const logger: SmartLogger = (msg: any, prec?: any) => {
  if (typeof msg === 'string') {
    console.log(`Logging string: ${msg}`);
  } else {
    console.log(`Logging number: ${msg} with precision ${prec}`);
  }
};

// TypeScript now knows exactly which overload is being used
logger("Hello");          // Valid
logger(123.456, 2);       // Valid
// logger(123.456);       // Error: No overload expects 1 argument when the first is a number

Notice that I didn't give the function a name like log(): void. That's the key. If I had given it a name, it would be a method on an object. By leaving it nameless, the object itself becomes the function.

Distinguishing Between Calling and Constructing

Now, let's push this further. Sometimes you aren't just dealing with a function, but a class—or something that acts like a class. This is where Construct Signatures come in. You'll recognize these because they use the new keyword.

I've seen people try to use a call signature to describe a class constructor, but that will fail the moment you try to use the new keyword. A call signature is for fn(); a construct signature is for new Fn().

interface Point {
  x: number;
  y: number;
}

// This interface describes a "Constructor" function
interface PointConstructor {
  new (x: number, y: number): Point;
}

function createPoint(Ctor: PointConstructor, x: number, y: number) {
  return new Ctor(x, y);
}

class MyPoint implements Point {
  constructor(public x: number, public y: number) {}
}

const p = createPoint(MyPoint, 10, 20);

In the example above, PointConstructor isn't an instance of a point; it's the blueprint for creating one. If you removed the new keyword, TypeScript would expect Ctor to be a regular function you call without new, and the createPoint function would throw a type error.

Personally, I find construct signatures incredibly useful when building factories or dependency injection containers where you need to pass a class around as a value but still ensure that whoever uses it can actually instantiate it correctly.




📋 Practical Task

Implementing a Typed Component Factory

You are building a UI library where components are created via a factory. You need to define a type that ensures the factory can only accept classes that follow a specific structure.

Your Goal:

  1. Create an interface Component that has a method render(): string.
  2. Create an interface ComponentConstructor that uses a construct signature to ensure any class passed to it returns a Component when instantiated with a name: string argument.
  3. Implement a class Button that satisfies the Component interface.
  4. Write a function createComponent that takes a ComponentConstructor and a string, and returns a new instance of that component.
// Your code here:

interface Component {
  // ...
}

interface ComponentConstructor {
  // ...
}

class Button implements Component {
  // ...
}

function createComponent(Ctor: ComponentConstructor, name: string) {
  // ...
}

// Test it
const myButton = createComponent(Button, "Submit");
console.log(myButton.render());
Rating
0 0

There are no comments for now.

to be the first to leave a comment.