Functions in TypeScript
Type function parameters, return values, optional and default parameters, overloads, and generics in TypeScript functions.
Typed Parameters and Return Types
Annotating function parameters is the single most impactful place to add types in TypeScript. Parameters are the contract between a function and its callers — explicit types on them mean TypeScript can verify every call site and surface mismatches immediately, rather than letting wrong arguments cause runtime errors deep inside the function.
function greet(name: string): string {
return `Hello, ${name}!`;
}
function add(a: number, b: number): number {
return a + b;
}
// void signals this function produces a side effect and returns nothing useful
function logError(message: string): void {
console.error(message);
}
Arrow functions use the same syntax:
const multiply = (a: number, b: number): number => a * b;
Type Inference for Return Types
TypeScript infers return types from the function body — you don’t always need to write them explicitly. However, writing them on public APIs has real value: it documents the contract, and if you accidentally return the wrong type inside the function, the error message points at the function body rather than every call site.
// TypeScript infers: (a: number, b: number) => number
const add = (a: number, b: number) => a + b;
Explicit return types are most valuable for:
- Public library APIs — the return type is part of the contract for consumers
- Complex functions where a mistake inside the body should be caught immediately
- Self-documenting intent in modules others will read and maintain
Optional Parameters
Optional parameters let callers omit an argument when it isn’t needed. The ? suffix makes a parameter optional, and inside the function its type becomes T | undefined. This is more honest than accepting undefined explicitly — it also means callers don’t have to pass undefined explicitly.
function createUser(name: string, role?: string): User {
return {
name,
role: role ?? "viewer", // provide a sensible default if role is undefined
};
}
createUser("Alice"); // fine — role defaults to "viewer"
createUser("Alice", "admin"); // fine — role is "admin"
Optional parameters must come after required ones.
Default Parameters
Default parameter values are a cleaner alternative to optional parameters when a sensible fallback exists. TypeScript infers the parameter type from the default value, so you usually don’t need to annotate it. Callers get accurate types and IDE hints showing the default.
function paginate(page: number = 1, pageSize: number = 20) {
const offset = (page - 1) * pageSize;
return { offset, limit: pageSize };
}
paginate(); // { offset: 0, limit: 20 } — uses both defaults
paginate(3); // { offset: 40, limit: 20 } — uses default pageSize
paginate(3, 10); // { offset: 20, limit: 10 } — both provided
Rest Parameters
Rest parameters collect any number of trailing arguments into a typed array. They replace the untyped arguments object and work naturally with spread syntax, giving you full type safety over variadic functions.
function sum(...numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0);
}
sum(1, 2, 3, 4); // 10 — any number of arguments, all type-checked
Function Types
Functions are first-class values in JavaScript, so TypeScript has syntax for describing their types. Naming function types with aliases makes higher-order code much more readable and lets you reuse the same signature across multiple places.
// Inline function type — works but can get verbose
let handler: (event: MouseEvent) => void;
// Named type aliases — reusable and self-documenting
type Predicate<T> = (item: T) => boolean;
type Transform<A, B> = (input: A) => B;
const isEven: Predicate<number> = (n) => n % 2 === 0;
const toString: Transform<number, string> = (n) => String(n);
Function Overloads
Overloads let you describe a function that behaves differently depending on what arguments it receives. Without overloads you’d be forced to use broad union types everywhere, losing the specific type information callers need. The overload signatures define the public API; the implementation signature handles all the cases internally.
// Overload signatures — what callers see
function format(value: string): string;
function format(value: number, decimals: number): string;
// Implementation signature — must be compatible with all overloads above
function format(value: string | number, decimals?: number): string {
if (typeof value === "string") {
return value.trim();
}
return value.toFixed(decimals ?? 2);
}
format(" hello "); // "hello" — TypeScript knows the return is string
format(3.14159, 2); // "3.14"
A more complex overload example — a scoped DOM query function where the second argument is optional:
function query(selector: string): Element | null;
function query(selector: string, scope: Element): Element | null;
function query(selector: string, scope?: Element): Element | null {
return (scope ?? document).querySelector(selector);
}
Generics in Functions
Generics solve the problem of writing a function that works with multiple types while preserving the relationship between input and output. Without generics, you either duplicate code for each type or fall back to any, losing all type information in the process.
// Without generics — callers lose the specific type
function identity(value: any): any {
return value;
}
// With generics — the output type is always the same as the input type
function identity<T>(value: T): T {
return value;
}
const s = identity("hello"); // type: string — not any
const n = identity(42); // type: number — not any
A practical generic that returns the first element of any array:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
first([1, 2, 3]); // type: number | undefined
first(["a", "b", "c"]); // type: string | undefined
Generic Constraints
Without constraints, a generic type parameter T could be anything — you can’t safely call any methods on it. The extends keyword lets you restrict what T can be, unlocking access to the properties and methods you need while keeping the function flexible.
// K must be a key of T — this prevents "email" if it doesn't exist on the object
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // type: string
getProperty(user, "age"); // type: number
getProperty(user, "email"); // Error: "email" does not exist on this type
A real-world example — a typed HTTP client helper that carries the expected response shape through the Promise:
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}
interface Post {
id: number;
title: string;
body: string;
}
// post.title is typed as string — no casting, no any
const post = await fetchJson<Post>("/api/posts/1");
this Parameter
JavaScript’s this binding is a common source of runtime bugs — detaching a method from its object and calling it loses the context. TypeScript lets you annotate the this type on a function, making illegal detachment a compile-time error instead of a runtime surprise.
interface Button {
label: string;
onClick(this: Button): void; // this must be a Button when onClick is called
}
const btn: Button = {
label: "Submit",
onClick() {
console.log(this.label); // TypeScript knows this is Button
},
};
// Prevents detaching the method and calling it with wrong context:
const handler = btn.onClick;
handler(); // Error: The 'this' context of type 'void' is not assignable to 'Button'
Callable Interfaces
Interfaces can describe objects that are both callable (like a function) and have additional properties. This pattern appears in libraries that return functions decorated with configuration or metadata.
interface Formatter {
(value: string): string; // the call signature
locale: string; // a property on the function object
}
function createFormatter(locale: string): Formatter {
const fmt = ((value: string) => value.toUpperCase()) as Formatter;
fmt.locale = locale;
return fmt;
}
Practical Example
This typed event emitter shows how generics in functions unlock precise types across a whole system. The event name and payload type are linked — emitting the wrong shape for a given event is a compile error.
type EventMap = {
login: { userId: string };
logout: { userId: string };
error: { message: string; code: number };
};
function createEmitter<TEvents extends Record<string, unknown>>() {
const listeners = new Map<keyof TEvents, Function[]>();
return {
on<K extends keyof TEvents>(event: K, handler: (data: TEvents[K]) => void) {
const handlers = listeners.get(event) ?? [];
listeners.set(event, [...handlers, handler]);
},
emit<K extends keyof TEvents>(event: K, data: TEvents[K]) {
listeners.get(event)?.forEach((h) => h(data));
},
};
}
const emitter = createEmitter<EventMap>();
// Handler receives the correctly typed payload — no casting needed
emitter.on("login", ({ userId }) => console.log(`User ${userId} logged in`));
emitter.emit("login", { userId: "123" }); // fine
emitter.emit("login", { wrong: "field" }); // Error — wrong payload shape