Skip to main content
TypeScript · lesson 5 of 21
TypeScript beginner Lesson 5 of 21

Functions in TypeScript

Typed parameters and returns, optional, default and rest parameters, overloads, generics and this — each shown with real tsc errors and tsx output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

Typed Parameters and Return Types

Parameter types are the contract with every caller, and the compiler checks each call site against it.

function greet(name: string): string {
  return `Hello, ${name}!`;
}

const add = (a: number, b: number): number => a + b;

console.log(greet("Alice"), add(2, 3));
console.log(greet(42));
console.log(add(2));
console.log(add(2, 3, 4));
$ npx tsc --noEmit --module nodenext basics.ts
basics.ts(8,19): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
basics.ts(9,13): error TS2554: Expected 2 arguments, but got 1.
basics.ts(10,23): error TS2554: Expected 2 arguments, but got 3.

Wrong type, too few arguments, too many arguments — all three are errors. The last one surprises people coming from JavaScript, where extra arguments are silently ignored. Arrow functions take the same annotations as function declarations.

Inferred versus Explicit Return Types

Leave off the return type and TypeScript infers it from the body. Emitting a declaration file shows exactly what it inferred:

export function parse(raw: string) {
  const n = Number(raw);
  if (Number.isNaN(n)) return null;
  return n;
}

export const double = (n: number) => n * 2;
$ npx tsc --declaration --emitDeclarationOnly --outDir types infer.ts
$ cat types/infer.d.ts
export declare function parse(raw: string): number | null;
export declare const double: (n: number) => number;

The return null path is visible in the inferred type: number | null. That’s accurate, but maybe not what you meant. An explicit return type turns the intent into a check, and the error lands inside the function instead of at every caller:

export function parse(raw: string): number {
  const n = Number(raw);
  if (Number.isNaN(n)) return null;
  return n;
}
$ npx tsc --noEmit --module nodenext explicit.ts
explicit.ts(3,24): error TS2322: Type 'null' is not assignable to type 'number'.

Explicit return types are worth writing on exported functions — they’re the part of the contract other modules depend on.

Optional and Default Parameters

? makes a parameter optional; its type inside the function becomes T | undefined. A default value makes it optional and guarantees a value.

function createUser(name: string, role?: string) {
  return { name, role: role ?? "viewer" };
}

function paginate(page = 1, pageSize = 20) {
  return { offset: (page - 1) * pageSize, limit: pageSize };
}

console.log(createUser("Alice"), createUser("Bob", "admin"));
console.log(paginate(), paginate(3), paginate(3, 10));
console.log(paginate(undefined, 5));
$ npx tsc --noEmit --module nodenext optional.ts
$ npx tsx optional.ts
{ name: 'Alice', role: 'viewer' } { name: 'Bob', role: 'admin' }
{ offset: 0, limit: 20 } { offset: 40, limit: 20 } { offset: 20, limit: 10 }
{ offset: 0, limit: 5 }

The defaults also give page and pageSize the type number without an annotation. Passing undefined explicitly triggers the default, which is how you skip an earlier parameter — a sign an options object would be clearer. Optional parameters must come after required ones:

function badOrder(role?: string, name: string) {
  return `${name}:${role}`;
}
console.log(badOrder("admin", "Alice"));
$ npx tsc --noEmit --module nodenext optional-order.ts
optional-order.ts(1,34): error TS1016: A required parameter cannot follow an optional parameter.

Rest Parameters

...name: T[] collects the remaining arguments into a typed array.

function sum(...numbers: number[]): number {
  return numbers.reduce((acc, n) => acc + n, 0);
}

function tag(label: string, ...values: (string | number)[]): string {
  return `${label}: ${values.join(", ")}`;
}

const scores = [90, 85, 77];
console.log(sum(), sum(1, 2, 3, 4), sum(...scores));
console.log(tag("mixed", "a", 1, "b"));
$ npx tsx rest.ts
0 10 252
mixed: a, 1, b

sum() with no arguments returns 0 because reduce starts from the initial value. Spreading an array into a rest parameter checks every element — spreading a string[] into sum would be an error.

Function Types

Functions are values, so they have types. Naming a function type with an alias makes callbacks readable.

type Predicate<T> = (item: T) => boolean;
type Transform<A, B> = (input: A) => B;

const isEven: Predicate<number> = (n) => n % 2 === 0;
const label: Transform<number, string> = (n) => `#${n}`;

function pipeline(values: number[], keep: Predicate<number>, show: Transform<number, string>) {
  return values.filter(keep).map(show);
}

console.log(pipeline([1, 2, 3, 4], isEven, label));

const broken: Predicate<number> = (n) => n.toUpperCase();
$ npx tsc --noEmit --module nodenext function-types.ts
function-types.ts(13,44): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

No annotation on n was needed: the variable’s type flows into the arrow function (contextual typing), so n is a number — and a number has no toUpperCase. Remove the last line and it runs:

$ npx tsx function-types.ts
[ '#2', '#4' ]

Overloads

Overload signatures describe the call shapes callers may use; one implementation handles them all and isn’t callable directly.

function format(value: string): string;
function format(value: number, decimals: number): string;
function format(value: string | number, decimals?: number): string {
  if (typeof value === "string") return value.trim();
  return value.toFixed(decimals);
}

console.log(JSON.stringify(format("  hello  ")));
console.log(format(3.14159, 2));
console.log(format(3.14159));
console.log(format("hi", 2));
$ npx tsc --noEmit --module nodenext overloads.ts
overloads.ts(10,20): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
overloads.ts(11,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

format(3.14159) would fit the implementation signature (where decimals is optional), but the implementation isn’t one of the choices — only the two overloads are. With one argument only the string overload has the right arity, so that’s the one the error message compares against, which is why it complains about string rather than a missing decimals. The point stands: callers get exactly the combinations you listed. Delete the last two lines and it runs:

$ npx tsx overloads.ts
"hello"
3.14

If a union parameter would describe the function just as well, prefer it: overloads are for when the return type or the allowed argument combinations change with the input.

Generic Functions

A type parameter links the input type to the output type. Compare it with any:

function identityAny(value: any): any {
  return value;
}

function identity<T>(value: T): T {
  return value;
}

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const a = identityAny("hello");
const b = identity("hello");
const c = first([10, 20]);

console.log(a.toFixed(1));
console.log(b.toFixed(1));
console.log(c.toFixed(1));
$ npx tsc --noEmit --module nodenext generics.ts
generics.ts(18,15): error TS2551: Property 'toFixed' does not exist on type '"hello"'. Did you mean 'fixed'?
generics.ts(19,13): error TS18048: 'c' is possibly 'undefined'.

identityAny returns any, so a.toFixed(1) passes the checker and would crash at runtime. identity returns the type it was given. Here that’s the literal type "hello" (a const-like inference for a string literal argument), and a string literal has no toFixed, so the same mistake is caught. first returns T | undefined because the array could be empty, and the compiler makes you deal with that.

Generic Constraints

extends restricts what a type parameter may be. K extends keyof T means “one of T’s keys”, which makes a type-safe property getter:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30 };

const n: string = getProperty(user, "name");
const a: number = getProperty(user, "age");
console.log(n, a);

getProperty(user, "email");
$ npx tsc --noEmit --module nodenext constraints.ts
constraints.ts(11,19): error TS2345: Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.

The return type T[K] is looked up per call, so "name" gives string and "age" gives number. The error lists the keys that are allowed. See the generics lesson for more constraint patterns.

The this Parameter

Detaching a method from its object loses this — a classic JavaScript bug. A this parameter (always first, erased from the output) lets the compiler catch it.

interface Button {
  label: string;
  describe(this: Button): string;
}

const btn: Button = {
  label: "Submit",
  describe() {
    return `Button: ${this.label}`;
  },
};

console.log(btn.describe());
const detached = btn.describe;
console.log(detached());
$ npx tsc --noEmit --module nodenext this-param.ts
this-param.ts(15,13): error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Button'.
$ npx tsx this-param.ts 2>&1 | head -6
Button: Submit
/root/ts/functions/this-param.ts:9
    return `Button: ${this.label}`;
                           ^

TypeError: Cannot read properties of undefined (reading 'label')

this inside a detached call is undefined in an ES module, so the runtime crash is on reading label of undefined. The compiler caught it at the call site. The usual fixes are btn.describe.bind(btn) or calling through the object.

Callable Objects

A call signature in an interface describes a function that also carries properties.

interface Formatter {
  (value: string): string;
  locale: string;
}

function createFormatter(locale: string): Formatter {
  const fmt = (value: string) => value.toLocaleUpperCase(locale);
  return Object.assign(fmt, { locale });
}

const tr = createFormatter("tr-TR");
const en = createFormatter("en-US");
console.log(tr.locale, tr("istanbul"));
console.log(en.locale, en("istanbul"));
$ npx tsc --noEmit --module nodenext callable.ts
$ npx tsx callable.ts
tr-TR İSTANBUL
en-US ISTANBUL

Object.assign returns the function with the property attached, and its return type satisfies Formatter without a cast. The output shows why locale matters: Turkish uppercases a dotted i to İ.

A Worked Example: A Typed Event Emitter

Generic methods link an event name to its payload type, so emitting the wrong payload is a compile error.

type EventMap = {
  login: { userId: string };
  error: { message: string; code: number };
};

function createEmitter<E extends Record<string, unknown>>() {
  const listeners: { [K in keyof E]?: ((data: E[K]) => void)[] } = {};
  return {
    on<K extends keyof E>(event: K, handler: (data: E[K]) => void) {
      (listeners[event] ??= []).push(handler);
    },
    emit<K extends keyof E>(event: K, data: E[K]) {
      listeners[event]?.forEach((h) => h(data));
    },
  };
}

const emitter = createEmitter<EventMap>();
emitter.on("login", ({ userId }) => console.log(`user ${userId} logged in`));
emitter.on("error", (e) => console.log(`error ${e.code}: ${e.message}`));

emitter.emit("login", { userId: "123" });
emitter.emit("error", { message: "disk full", code: 507 });
emitter.emit("login", { message: "oops" });
$ npx tsc --noEmit --module nodenext emitter.ts
emitter.ts(24,25): error TS2353: Object literal may only specify known properties, and 'message' does not exist in type '{ userId: string; }'.

The handler for "error" never annotated e, yet e.code is a number — K was inferred as "error", so the handler type is (data: EventMap["error"]) => void. Remove the bad emit and it runs:

$ npx tsx emitter.ts
user 123 logged in
error 507: disk full

Practice

1. Add an overload to format that accepts a Date and returns its ISO date (YYYY-MM-DD).

Solution
function format(value: string): string;
function format(value: number, decimals: number): string;
function format(value: Date): string;
function format(value: string | number | Date, decimals?: number): string {
  if (typeof value === "string") return value.trim();
  if (value instanceof Date) return value.toISOString().slice(0, 10);
  return value.toFixed(decimals);
}

console.log(format(new Date(Date.UTC(2026, 8, 30))), format(2.5, 1));
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
2026-09-30 2.5

The implementation signature has to accept every overload’s parameters, so its first parameter widens to string | number | Date. Date.UTC avoids a timezone shifting the date. Months are 0-based, so 8 is September.

2. Write a generic last<T>(items: T[]): T | undefined and show that last(["a", "b"]) can’t be used as a string without a check.

Solution
function last<T>(items: T[]): T | undefined {
  return items[items.length - 1];
}

const x = last(["a", "b"]);
console.log(x?.toUpperCase(), last([]));
const s: string = x;
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(7,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
  Type 'undefined' is not assignable to type 'string'.

x is string | undefined. ?. handles the empty case; assigning it straight to a string is rejected.

3. Give the emitter a "logout" event with payload { userId: string; reason: "timeout" | "manual" }, and show that emitting reason: "crash" fails.

Solution
type EventMap = {
  logout: { userId: string; reason: "timeout" | "manual" };
};

function createEmitter<E extends Record<string, unknown>>() {
  const listeners: { [K in keyof E]?: ((data: E[K]) => void)[] } = {};
  return {
    on<K extends keyof E>(event: K, handler: (data: E[K]) => void) {
      (listeners[event] ??= []).push(handler);
    },
    emit<K extends keyof E>(event: K, data: E[K]) {
      listeners[event]?.forEach((h) => h(data));
    },
  };
}

const emitter = createEmitter<EventMap>();
emitter.on("logout", (e) => console.log(`${e.userId} left (${e.reason})`));
emitter.emit("logout", { userId: "7", reason: "timeout" });
emitter.emit("logout", { userId: "7", reason: "crash" });
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(20,39): error TS2322: Type '"crash"' is not assignable to type '"manual" | "timeout"'.

Only EventMap changed; the emitter code picks the new event up automatically. The literal union on reason is checked like any other property.

Frequently Asked Questions

Does TypeScript infer return types automatically?
Yes. TypeScript infers the return type from the function body. Explicit return type annotations are optional but recommended for public APIs — they serve as documentation and catch mistakes inside the function.
What are function overloads used for?
Overloads let you describe a function that behaves differently depending on its argument types. They give callers accurate types without forcing them to use union types everywhere.
When should I make a function generic?
When the function works with multiple types but the output type depends on the input type. If you find yourself using any or casting, a generic is usually the right solution.
What is the difference between an optional parameter and a default parameter?
An optional parameter (name?: string) is undefined when omitted, so its type inside the function is string | undefined and you must handle that case. A default parameter (name = "guest") is replaced with the default when omitted, so inside the function it is always a string.
What does a this parameter do in TypeScript?
A this parameter is a fake first parameter that declares what this must be when the function is called. It is erased from the JavaScript output, but the compiler rejects calls where this would be wrong, such as calling a method after detaching it from its object.