TypeScript Function Types in Interfaces
Interfaces don’t just describe data shapes — they can describe functions too. A property on an interface can itself be a function, and TypeScript gives you two different-looking syntaxes to declare that: method shorthand and a function-typed property. It also lets an interface describe a value that is itself callable, like a function with extra properties attached. Understanding these forms — and the subtle differences between them — is essential once you start typing callbacks, event handlers, and APIs that accept behavior as data.
Overview: How Function Types Work in Interfaces
In JavaScript, functions are values, so any property of an object can hold a function. TypeScript needs a way to describe the shape of that function — its parameter types and return type — as part of an interface. There are three related constructs:
- Method shorthand —
area(): number;— declares a member that behaves like a method. - Function-typed property —
perimeter: () => number;— declares a property whose value happens to be a function. - Call signature —
(input: string): number;written directly inside an interface with no name — describes an object that can be called like a function (optionally also having properties, which is how libraries type things like a callable object with attached helper methods).
Structurally, method shorthand and function-typed properties describe the same runtime shape (“an object with a property whose value is a function”), and you can usually implement either with a plain function or an arrow function. But the compiler treats them slightly differently when checking assignability, which matters once you assign a more specific function to a general interface. Under strictFunctionTypes (part of strict mode), function-typed properties are checked contravariantly on their parameters — an assigned function’s parameters must be as wide or wider than the interface expects. Method-shorthand members, however, are deliberately checked bivariantly — TypeScript allows the parameter type to be narrower too. This exception exists because in practice, method overriding patterns (like array callbacks or DOM event handlers) are extremely common and usually safe in practice, even though technically unsound in rare cases. This is one of the most-asked-about quirks of the type system, and knowing it exists will save you a lot of confusion when a callback compiles in one spot but not another.
Remember also that all of this is purely a compile-time concept. TypeScript’s type annotations, interfaces, and signatures are erased entirely when the code is compiled to JavaScript — at runtime there is no interface, no signature, and no check; only plain functions and objects remain.
Syntax
The general forms look like this:
interface Shape {
// Method shorthand syntax
area(): number;
// Property with function type syntax
perimeter: () => number;
// Optional method
describe?(): string;
}
interface Callable {
(input: string): number;
}
area(): number;— method shorthand: name, parameter list, return type.perimeter: () => number;— property name, colon, then a function type using the arrow-type syntax.describe?(): string;— the?marks the method optional; callers must guard againstundefinedbefore invoking it.(input: string): number;— a call signature with no name, placed directly in the interface body; any value assigned toCallablemust itself be invocable with astringargument and return anumber.
Examples
Example 1: Method shorthand and function-typed properties side by side
interface Calculator {
add(a: number, b: number): number;
subtract: (a: number, b: number) => number;
}
const calc: Calculator = {
add(a, b) {
return a + b;
},
subtract: (a, b) => a - b,
};
console.log(calc.add(5, 3));
console.log(calc.subtract(5, 3));
Output:
8
2
Both add and subtract are functions on the object, and TypeScript infers the parameter types a and b from the interface, so you don’t need to annotate them again in the implementation. Whether you write a member as a method or as an arrow-typed property is mostly a style choice for simple cases like this — the difference only becomes visible when assignability and variance come into play, shown in Example 3.
Example 2: Call signatures for callable objects
interface Greeter {
(name: string): string;
formal?: (name: string) => string;
}
function makeGreeter(): Greeter {
const greeter = ((name: string) => `Hi, ${name}!`) as Greeter;
greeter.formal = (name: string) => `Good day, ${name}.`;
return greeter;
}
const greet = makeGreeter();
console.log(greet("Sam"));
if (greet.formal) {
console.log(greet.formal("Sam"));
}
Output:
Hi, Sam!
Good day, Sam.
The Greeter interface has a call signature (making any Greeter directly invocable, like greet("Sam")) plus an optional formal method attached to it, just like real-world APIs where a function also carries extra helper methods (Node’s util.promisify and jQuery’s $ are classic examples of “callable objects with properties”). Because a plain arrow function doesn’t naturally have a formal property, we build it in two steps: create the base function and assert it as Greeter, then attach formal afterward. The if (greet.formal) check is required because formal is optional — without it, TypeScript would refuse to let you call something typed as possibly undefined.
Example 3: Method shorthand vs. property syntax and variance
interface AnimalEvent {
type: string;
}
interface DogEvent extends AnimalEvent {
breed: string;
}
interface EventBusMethod {
on(event: AnimalEvent): void;
}
interface EventBusProperty {
on: (event: AnimalEvent) => void;
}
const methodStyle: EventBusMethod = {
on(event: DogEvent) {
console.log(`Handling dog: ${event.breed}`);
},
};
const propertyStyle: EventBusProperty = {
on: (event: AnimalEvent) => {
console.log(`Handling animal: ${event.type}`);
},
};
const dogEvent: DogEvent = { type: "dog", breed: "Labrador" };
methodStyle.on(dogEvent);
propertyStyle.on({ type: "cat" });
Output:
Handling dog: Labrador
Handling animal: cat
Notice that methodStyle.on is implemented with a narrower parameter type (DogEvent) than the interface declares (AnimalEvent). This compiles because on was declared using method shorthand in EventBusMethod, which TypeScript checks bivariantly. If you tried the same narrowing trick on EventBusProperty — giving its on property an implementation typed as (event: DogEvent) => void — the compiler would reject it, because function-typed properties are checked contravariantly under strict mode. Same-looking code, different rules, depending purely on which syntax the interface used to declare the member.
Under the Hood
- When you write
const calc: Calculator = { ... }, TypeScript compares the shape of the object literal against theCalculatorinterface member by member. - For each function member, it compares parameter types and return types structurally — names don’t matter, only the types and order/count of parameters, and whether the return type is compatible.
- Parameters are generally checked so the implementation’s parameter types must be the same or wider than the interface’s declared parameter types (contravariance), because callers will invoke the function using the interface’s narrower expectations, and the implementation must be able to handle whatever the interface promises to send it.
- Method-shorthand members are given a deliberate exception (bivariant checking) for practical compatibility with common JavaScript override patterns.
- Optional members (
describe?()) are typed as(() => string) | undefinedinternally, which is whystrictNullChecksforces you to guard before calling them. - Once compilation finishes, all of this — interfaces, signatures, optionality markers — is deleted. The emitted JavaScript contains only the object literals and function bodies; there is no runtime trace of the type checking that happened.
Common Mistakes
Mistake 1: Adding an extra required parameter to a function-typed property
interface Logger {
log: (message: string) => void;
}
const badLogger: Logger = {
log: (message: string, level: string) => {
console.log(`[${level}] ${message}`);
},
};
TypeScript reports: Type '(message: string, level: string) => void' is not assignable to type '(message: string) => void'. Target signature provides too few arguments. Expected 2 or more, but target provides 1. Callers of Logger.log are only obligated to pass one argument, so a required second parameter can never reliably receive a value.
Fix it by making the extra parameter optional so the implementation tolerates being called with just one argument:
interface Logger {
log: (message: string, level?: string) => void;
}
const goodLogger: Logger = {
log: (message: string, level?: string) => {
console.log(level ? `[${level}] ${message}` : message);
},
};
goodLogger.log("Server started");
goodLogger.log("Disk usage high", "WARN");
Output:
Server started
[WARN] Disk usage high
Mistake 2: Calling an optional method without checking it exists
interface Widget {
render(): void;
onDestroy?(): void;
}
const widget: Widget = {
render() {
console.log("Rendering widget");
},
};
widget.onDestroy();
Since onDestroy is optional, its type includes undefined, so tsc reports: Cannot invoke an object which is possibly 'undefined'. The fix is optional chaining, which calls the method only if it exists and otherwise evaluates to undefined safely:
interface Widget {
render(): void;
onDestroy?(): void;
}
const widget: Widget = {
render() {
console.log("Rendering widget");
},
};
widget.onDestroy?.();
console.log("Widget cleanup complete");
Output:
Widget cleanup complete
Best Practices
- Prefer method shorthand (
doThing(): void;) for members meant to behave like object methods, especially for callback-heavy APIs where you expect implementers to narrow parameter types. - Prefer function-typed properties (
doThing: () => void;) when you want strict, safe contravariant checking — for example, when the function member is treated as pure data being passed around, not an overridable method. - Use call signatures only when a value genuinely needs to be invoked directly, not just contain a method — don’t reach for them just to avoid naming a property.
- Mark methods optional (
?) only when omission is a real, intended possibility, and always guard with?.()or anifcheck before calling them. - Give every parameter and return type in a function-type member an explicit type — don’t rely on implicit
any, which defeats the purpose of the interface. - When designing a public API, document which style (method vs. property) you intend implementers to use, since the two are not interchangeable once variance matters.
Practice Exercises
- Define an interface
Validatorwith a method shorthand memberisValid(value: string): boolean;and a function-typed property memberformat: (value: string) => string;. Implement an object that checks a string is non-empty and formats it by trimming whitespace, then log the results for a sample input. - Define a callable interface
Middlewarewith a call signature(request: string): string;plus an optionalname?: string;property. Create a middleware function that uppercases the request string, attach anameto it, and log both the transformed request and the name (guarding for it being optional). - Create two interfaces, one using method shorthand and one using a function-typed property, both describing a
handlemember that accepts a base event type. Try implementing each with a handler that accepts a more specific (narrower) subtype, and predict — before checking — which one the compiler accepts.
Summary
- Interfaces can describe function-shaped members using method shorthand (
name(): T) or function-typed properties (name: () => T) — both describe “an object with a callable property,” but they aren’t checked identically. - Call signatures (
(args): T;with no name) make an entire interface describe a callable value, optionally with additional properties attached. - Method-shorthand members are checked bivariantly on parameters; function-typed properties are checked contravariantly under
strictFunctionTypes— this affects whether narrower parameter types are accepted. - Optional methods (
name?()) must be guarded with a check or optional chaining (?.()) before being invoked. - All interface and function-type information is erased at compile time — none of it exists in the emitted JavaScript.
