TL;DR
Learn how to write typed functions in TypeScript with optional parameters, overloads, and return types for safer, documented code.
Key concepts
- TypeScript functions
- typed functions
- function overloads TypeScript
- arrow functions TypeScript
Functions
TL;DR — Annotate function parameters and return types to lock down the contract:
function add(a: number, b: number): number. Use?for optional params,= valuefor defaults,...args: T[]for rest params. Arrow functions share the same annotation syntax. TypeScript infers return types, but explicit annotations improve readability and IDE tooltips.
Functions are the building blocks of readable, maintainable code. In TypeScript, functions can be typed for parameters and return values, providing better safety and documentation.
What You'll Learn
By the end of this lesson you will finish three small functions — one written as a declaration, one as an arrow function, and one with a default parameter — and prove them correct against checks that fail until each returns the right value.
Functions are where type annotations start doing real work. A variable's type is usually obvious from the value sitting next to it; a function's parameter types are not, because at the point you read the parameter there is no value to look at. That is the gap annotations fill, and it is why this lesson matters more than its size suggests: every later lesson is written in terms of functions that take and return described things. You arrive here able to name the basic types and read an annotation (Variables and Types); what is new is the contract — what goes in, what comes out — and the rules that decide when you must write it down.
Basic Function Syntax
Here's how you define a simple function in TypeScript:
function greet(): void {
console.log("Hello, TypeScript!");
}
greet();
Functions with Parameters
Here is the rule that decides when you must write an annotation and when you can leave it off, and it is worth learning as a rule rather than a habit, because it predicts cases this lesson never shows you: TypeScript infers a type when there is something in scope to infer it from, and requires an annotation when there is not.
A parameter in a function declaration has nothing to infer from — the function is written before any call to it exists, so the compiler is not looking at an argument when it reads name. You must say what it is. But the same parameter can be inferred when the function is written in a position that already has a type: assign an arrow function to a variable annotated with a function type, and the parameters get their types from that annotation, so you write nothing. You will see exactly that a few sections below, where const add: MathOperation = (a, b) => a + b needs no annotations on a and b at all. Return types follow the same logic in reverse: the compiler can always see the return statements, so it can infer them — which is why a return annotation is optional, and why writing one anyway is a choice about documentation and about catching the case where the body returns something other than what you intended.
Functions can take typed parameters:
function greetPerson(name: string): void {
console.log(`Hello, ${name}!`);
}
greetPerson("Alice");
Return Types
Functions can return values with explicit type annotations:
function add(x: number, y: number): number {
return x + y;
}
const result = add(5, 3);
console.log(`5 + 3 = ${result}`);
Optional Parameters
Parameters can be made optional using the ? operator:
function buildName(firstName: string, lastName?: string): string {
if (lastName) {
return `${firstName} ${lastName}`;
}
return firstName;
}
console.log(buildName("Alice"));
console.log(buildName("Bob", "Smith"));
Default Parameters
You can provide default values for parameters:
function greetWithDefault(name: string = "Guest"): void {
console.log(`Hello, ${name}!`);
}
greetWithDefault();
greetWithDefault("Alice");
The default is what makes that first call legal. Without one, a required parameter must be supplied, and leaving it out is rejected before the program runs. This fence is marked no-run because its whole point is to fail to compile:
function describe(value: number, tag: string): string {
return `${tag}: ${value}`;
}
console.log(describe(15));
Checked with tsc --strict, that is rejected with:
error TS2554: Expected 2 arguments, but got 1.
The runtime shadow is the interesting half, and it is what the next block is about: the missing argument does not stop the program. It arrives as undefined and gets interpolated into the string like any other value.
Debug
This should label a value with a default tag, so describe(15) is expected to produce 'shape: 15'. Instead the check fails on a string that has the word undefined in it, and the program never printed a thing. Commit a hypothesis about what tag holds when the caller passes only one argument, then fix the signature so describe(15) returns 'shape: 15'.
import assert from "node:assert";
function describe(value: number, tag: string): string {
return `${tag}: ${value}`;
}
const labelled = describe(15);
assert.strictEqual(labelled, "shape: 15", "describe(15) should fall back to the default tag, got " + labelled);
console.log("described:", labelled);Expected output:
described: shape: 15
Recall
Reaching back to Variables and Types, no scrolling: when an optional parameter like lastName?: string is left out at the call site (buildName('Alice')), what value does it hold inside the function?
Arrow Functions
Arrow functions provide a concise syntax for writing functions:
// Regular function
function multiply(a: number, b: number): number {
return a * b;
}
// Arrow function
const multiplyArrow = (a: number, b: number): number => {
return a * b;
};
// Concise arrow function: the braces and the return are both gone.
// TODO: write this third form yourself. Same parameters, same result, but the
// body is just the expression a * b — no { }, no return. Replace the 0 below,
// then run it: the third line prints 0 until you do.
const multiplyShort = (a: number, b: number): number => 0;
console.log(`multiply(5, 3) = ${multiply(5, 3)}`);
console.log(`multiplyArrow(5, 3) = ${multiplyArrow(5, 3)}`);
console.log(`multiplyShort(5, 3) = ${multiplyShort(5, 3)}`);
The three forms differ only in how much ceremony they carry, and the choice between them is about what the reader needs. Reach for the declaration when the function is a named thing the file is about, because function declarations are hoisted and read as top-level structure. Reach for the concise arrow when the function is an argument to something else — a map or a filter callback — where a return keyword and a pair of braces are noise around a single expression. The middle form is what you write when the body genuinely needs more than one statement. Note one trap in the third form that costs people real time: adding braces to a concise arrow silently changes its meaning, because a { } body does not return its last expression. (a, b) => a * b returns the product; (a, b) => { a * b; } returns undefined.
Function Types
You can define types for functions:
// Function type
type MathOperation = (a: number, b: number) => number;
const add: MathOperation = (a, b) => a + b;
const subtract: MathOperation = (a, b) => a - b;
console.log(`10 + 5 = ${add(10, 5)}`);
console.log(`10 - 5 = ${subtract(10, 5)}`);
Rest Parameters
Functions can accept a variable number of arguments:
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
console.log(`sum(1, 2, 3) = ${sum(1, 2, 3)}`);
console.log(`sum(1, 2, 3, 4, 5) = ${sum(1, 2, 3, 4, 5)}`);
Generic Functions
Generics allow you to write reusable functions that work with different types. This is just an intro — the Generics lesson covers constraints, conditional types, and real-world patterns in depth.
// Generic function
function identity<T>(arg: T): T {
return arg;
}
const numberResult = identity<number>(42);
const stringResult = identity<string>("Hello");
console.log(`Number identity: ${numberResult}`);
console.log(`String identity: ${stringResult}`);
// TypeScript can infer the generic type
const inferredNumber = identity(42);
const inferredString = identity("Hello");
console.log(`Inferred number: ${inferredNumber}`);
console.log(`Inferred string: ${inferredString}`);
Predict
Here identity is called with no <...> type argument written. TypeScript infers T from the value you pass. What type does the compiler infer for result?
function identity<T>(arg: T): T {
return arg;
}
let result = identity("Hello");Arrange the code
These four lines define a typed arrow function, call it, build a label from the result, and print it. The pieces are shuffled. Put them back in an order that runs — then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?
const label = `total: ${doubled}`;const price = (base: number): number => base * 2;const doubled = price(21);console.log(label);
Transfer
JavaScript has always allowed calling a function with fewer arguments than it declares; the missing ones are undefined and the call proceeds. TypeScript rejects that same call at compile time unless the parameter is marked optional with ? or given a default with = value. The underlying runtime behaviour did not change. Which statement describes what the annotation actually adds?
Try It Yourself
Practice writing functions in the playground below:
// Basic function
function greet(name: string): string {
return `Hello, ${name}!`;
}
console.log(greet("TypeScript"));
// Function with multiple parameters
function calculateRectangleArea(width: number, height: number): number {
return width * height;
}
console.log(`Area of 5x3 rectangle: ${calculateRectangleArea(5, 3)}`);
// Arrow function
const double = (n: number): number => n * 2;
console.log(`double(21) = ${double(21)}`);
// Function with optional parameter
function createGreeting(name: string, title?: string): string {
if (title) {
return `Hello, ${title} ${name}!`;
}
return `Hello, ${name}!`;
}
console.log(createGreeting("Alice"));
console.log(createGreeting("Smith", "Dr."));
// Function with default parameter
function power(base: number, exponent: number = 2): number {
return Math.pow(base, exponent);
}
console.log(`3^2 = ${power(3)}`);
console.log(`2^3 = ${power(2, 3)}`);
// Rest parameters
function average(...numbers: number[]): number {
const sum = numbers.reduce((total, n) => total + n, 0);
return sum / numbers.length;
}
console.log(`Average of 10, 20, 30: ${average(10, 20, 30)}`);
Try these exercises:
- Create a function that takes two strings and returns them concatenated
- Write an arrow function that calculates the area of a circle
- Create a function with an optional parameter for a discount percentage
- Write a function using rest parameters to find the maximum number
- Create a generic function that returns the first element of an array
Reading about functions is not the same as writing them. This is a build task: a small program that reports its own pass/fail. You are given fixed rectangle data and three empty functions to finish — a declaration, an arrow function, and one with a default parameter, each fully typed. Run it and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.
The three functions reuse exactly what this lesson taught: writing a typed declaration and a typed arrow function, taking parameters, returning a value with return, and giving a parameter a default. The starter already has the data, the stubs, and the checks — you write only the body of each function. Nothing above spells out all three answers, so you will have to assemble them yourself.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// The data: rectangle dimensions to measure. Do NOT change this array.
const rectangles: { label: string; width: number; height: number }[] = [
{ label: "small", width: 5, height: 3 },
{ label: "wide", width: 10, height: 4 },
{ label: "square", width: 6, height: 6 },
];
// TODO 1: return the area of a rectangle (width times height).
// area(5, 3) -> 15
function area(width: number, height: number): number | undefined {
return undefined; // replace undefined with your code
}
// TODO 2: return the perimeter of a rectangle: 2 * (width + height).
// perimeter(5, 3) -> 16
const perimeter = (width: number, height: number): number | undefined => {
return undefined; // replace undefined with your code
};
// TODO 3: return a label like "small: 15" — the tag, a colon, a space, then the value.
// Give tag a default of "shape", so describe(15) uses "shape".
// describe(15, "small") -> "small: 15"
// describe(15) -> "shape: 15"
function describe(value: number, tag: string = "shape"): string | undefined {
return undefined; // replace undefined with your code
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(
area(5, 3),
15,
"area(width, height) should return width times height",
);
assert.strictEqual(
perimeter(5, 3),
16,
"perimeter(width, height) should return 2 * (width + height)",
);
assert.strictEqual(
describe(15),
"shape: 15",
"describe(value) should default its tag to 'shape' when no tag is passed",
);
assert.strictEqual(
describe(15, "small"),
"small: 15",
"describe(value, tag) should join the tag and value as 'tag: value'",
);
console.log("All checks passed.");
console.log("Area 5x3:", area(5, 3));
console.log("Perimeter 5x3:", perimeter(5, 3));
console.log("Described:", describe(15, rectangles[0].label));Expected output:
All checks passed. Area 5x3: 15 Perimeter 5x3: 16 Described: small: 15
Once it passes, try two variations and predict each before running:
- Drop the default. Rewrite
describe's signature asfunction describe(value: number, tag: string): string | undefined— no= "shape"default. Predict whatdescribe(15)returns with no second argument before running it. The runner strips types and does not stop you, sotagarrives asundefined, the string becomes"undefined: 15", and the default-tag check fails — the exact reason the default parameter was there. - The arrow that forgets to return. Give
perimetera block body with noreturn: changereturn 2 * (width + height);to just2 * (width + height);. Decide whatperimeter(5, 3)yields before running. A{ }arrow body does not return its last expression the way a concise=> 2 * (width + height)body does, so the function returnsundefinedand the perimeter check fails.
Key Takeaways
- Functions can have typed parameters and return types
- Use
?for optional parameters and=for default values - Arrow functions provide concise syntax
- Rest parameters allow variable-length argument lists
- Generics enable reusable, type-safe functions
- TypeScript infers return types, but explicit types improve clarity
Pro Tip: Start by writing your functions with explicit type annotations. As you become more comfortable, you can rely on TypeScript's type inference for simpler cases!
Next Steps
With typed functions under your belt, you're ready to control how your program flows. Next up: conditionals, loops, switch statements, and how TypeScript narrows types inside control flow branches.
Ready to continue? Head to Control Flow!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.