05 — Variables, Functions and Parameters
Level: Beginner · Time: ~25 min · Prerequisites: Lesson 4 — Primitive Types and Type Inference
Why this matters
A function signature — its parameters, their optionality, and its return type — is the smallest useful contract in software: it states what the caller must provide and what they get back, without a word of comment. Nearly every refactor you perform is a change to that contract. JavaScript accumulated three ways to declare a function and five ways to pass parameters, and knowing which form makes the contract clearest matters more than knowing all of them; the form used most in modern TypeScript is usually the one with the fewest annotations and the narrowest scope.
Scope and declarations: const, let and var
const annualKwh = 5_760; // block-scoped, cannot be reassigned
let processed = 0; // block-scoped, reassignable
var legacy = "no"; // function-scoped, hoisted, undefined before its line — do not use
// annualKwh = 5_800;
// ❌ TS2588: Cannot assign to 'annualKwh' because it is a constant.
function report(): string {
// console.log(annualKwh); ❌ TS2448: Block-scoped variable 'annualKwh' used before its declaration.
const annualKwh = 5_760; // the temporal dead zone ends here
return `${annualKwh} kWh`;
}
console.log(report(), processed++, legacy); // 5760 kWh 0 no
var ignores block boundaries: written inside an if, it leaks out and reads as undefined before the line that assigns it. Nothing in new code needs it. The temporal dead zone is the region between entering a block and reaching a let or const declaration — reading a variable there is TS2448, not a silent undefined. And const protects the binding, not the value: const readings = [1, 2] still allows readings.push(3). Immutability proper arrives with readonly in Lesson 6.
Shadowing — declaring a name that already exists in an enclosing scope — is legal, and it is a reliable source of "but that variable holds the wrong value": a const region = "Thiès" inside a function hides the outer region = "Dakar" for the rest of that function only, while the outer binding is untouched. Use const by default, let only when you must reassign, one meaning per name, and no shadowing.
Three ways to write a function
// 1. declaration — hoisted: callable on a line above its own definition
function kwhPerDay(annualKwh: number, days: number): number {
return annualKwh / days;
}
// 2. function expression — assigned to a const, so NOT hoisted
const kwhPerDayExpression = function (annualKwh: number, days: number): number { return annualKwh / days; };
// 3. arrow function — no own `this`, no own `arguments`
const kwhPerDayArrow = (annualKwh: number, days: number): number => annualKwh / days;
console.log(kwhPerDay(5_760, 365).toFixed(1), kwhPerDayExpression(5_760, 365).toFixed(1), kwhPerDayArrow(5_760, 365).toFixed(1));
// 15.8 15.8 15.8
- Arrow functions for anything passed as a value: callbacks, array methods, event handlers.
- Declarations for top-level named functions a reader should find easily, and where hoisting helps.
this is why the distinction matters, and Lesson 13 gives it a full treatment: a function declaration or expression gets its own this, decided by how it is called, while an arrow inherits this from where it was written. Inside an object method, that is the difference between working code and undefined.
Parameters: optional, default, rest and destructured
function dailyYield(annualKwh: number, days = 365, label?: string): string {
const value = annualKwh / days;
return label === undefined ? `${value.toFixed(1)} kWh/day` : `${label}: ${value.toFixed(1)} kWh/day`;
}
function totalKwh(scale: number, ...readings: number[]): number {
let total = 0;
for (const reading of readings) total += reading * scale; // rest collects into a real array
return total;
}
console.log(dailyYield(5_760)); // 15.8 kWh/day
console.log(dailyYield(5_760, 366)); // 15.7 kWh/day
console.log(dailyYield(5_760, 365, "Sokone")); // Sokone: 15.8 kWh/day
console.log(totalKwh(1, 18.4, 21.9, 12.6).toFixed(1)); // 52.9
- Required parameters come first; omitting one is
TS2554. - Default values make a parameter optional and give it a value — passing
undefinedalso triggers the default. - Optional (
label?: string) isstring | undefinedinside the body, which is why the example must test forundefined; an optional parameter must follow all required ones.
Destructured object parameters are the form to prefer once a function needs more than three arguments: the call site documents itself, order stops mattering, and defaults sit beside their name. A boolean parameter is the classic case — compute(true, false, true) is unreadable where compute({ grid: true, weekly: false, verbose: true }) explains itself.
type Reading = { readonly region: string; readonly kwh: number; readonly source?: string };
function describe({ region, kwh, source = "PVGIS" }: Reading): string {
return `${region}: ${kwh.toFixed(1)} kWh (${source})`;
}
console.log(describe({ region: "Dakar", kwh: 5_412.5 })); // Dakar: 5412.5 kWh (PVGIS)
Return types, async functions and callbacks
The compiler infers every return type correctly, so annotating one is a documentation decision: annotate exported functions and anything whose type is hard to read from the body, and leave short local helpers inferred. Two return types are special. void means the function is called for its effect, not its value — console.log returns void. never means it never returns normally, because it throws or loops forever, which is how Lesson 4 proved a switch exhaustive.
function label(kwh: number): string { return `${kwh.toFixed(1)} kWh`; } // explicit at a boundary
function fail(message: string): never { throw new Error(message); } // returns nothing at all
An async function always returns a promise, whatever it looks like inside, so annotate Promise<T> and let the resolved value be inferred at the await:
import { setTimeout as delay } from "node:timers/promises";
async function yieldAfterDelay(kwh: number, ms: number): Promise<number> {
await delay(ms); // Node's own promise-based timer
return kwh;
}
const resolved: number = await yieldAfterDelay(18.4, 10); // top-level await works in ESM
Array methods are where inference pays off, because the callback parameter types are derived from the array — none of this needs an annotation, and all of it is checked:
const readings = [18.4, 21.9, 12.6];
const good = readings.filter((kwh) => kwh > 15); // number[]
const total = readings.reduce((sum, kwh) => sum + kwh, 0); // number, 0 seeds the accumulator
const labelled = readings.map((kwh): string => `${kwh.toFixed(1)} kWh`); // string[]
When a callback's types look wrong, the bug is upstream: the array is not what you think it is. For functions that take functions, name the contract once with a type alias. Note that the parameters of monthlyAverage carry no annotations — assigning the function to a variable typed YieldCalculator lets the compiler infer them from the contract, so the type is written once, at the boundary.
type YieldCalculator = (annualKwh: number, days: number) => number;
const monthlyAverage: YieldCalculator = (annualKwh, days) => annualKwh / days / 12;
console.log(monthlyAverage(5_760, 365).toFixed(1)); // 1.3
Overloads, and when they are the wrong answer
An overload declares the call shapes a function accepts, then provides one implementation:
function parseKwh(input: string): number;
function parseKwh(input: number): number;
function parseKwh(input: string | number): number {
return typeof input === "number" ? input : Number.parseFloat(input);
}
console.log(parseKwh("18.4"), parseKwh(18.4)); // 18.4 18.4
Only the two overload signatures are visible to callers; the implementation signature is not callable directly, which is why it must be compatible with every overload above it. Overloads earn that complexity only when the return type depends on the input — a function returning a string for a string argument and a number for a number argument. If the parameters differ but the return type does not, a union parameter says the same thing in one line. And if only the element type varies — the same logic for numbers today, yield records tomorrow — a generic is the right tool (see Lesson 9 — Generics). All three express "many shapes, one behaviour"; reach for the simplest that still tells the truth.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
| Calling a function expression before its declaration | ReferenceError: Cannot access 'x' before initialization — unlike a declaration it is not hoisted |
Use a declaration for hoisted helpers, or move the call below it |
Relying on this inside an arrow function |
this is inherited from the enclosing scope, so it is undefined where a method expected the object |
Use a method or a function declaration where this must be the caller |
| Putting an optional parameter before a required one | A caller is forced to pass undefined to reach the later argument |
Order them: required, then defaulted, then optional |
| Annotating every callback parameter | Noise to maintain, and a second place for the type to go stale | Let array methods infer the callback parameters |
| Reaching for overloads when a union would do | Two signatures, one implementation, and maintenance with no gain | Use a union parameter, or a generic when the element type varies |
Try it
- Create
src/functions.tsand run it withnpx tsx src/functions.ts:
type Reading = { readonly region: string; readonly kwh: number; readonly source?: string };
function describe({ region, kwh, source = "PVGIS" }: Reading): string {
return `${region}: ${kwh.toFixed(1)} kWh (${source})`;
}
function totalKwh(scale: number, ...readings: number[]): number {
return readings.reduce((sum, kwh) => sum + kwh, 0) * scale;
}
const readings: readonly Reading[] = [
{ region: "Dakar", kwh: 5_412.5 },
{ region: "Thiès", kwh: 4_980, source: "inverter" },
{ region: "Sokone", kwh: 6_105.28, source: "inverter" },
];
for (const reading of readings) console.log(describe(reading));
console.log(`Total: ${totalKwh(1, ...readings.map((r) => r.kwh)).toFixed(1)} kWh`);
Expected output:
Dakar: 5412.5 kWh (PVGIS)
Thiès: 4980.0 kWh (inverter)
Sokone: 6105.3 kWh (inverter)
Total: 16497.8 kWh
Two rules are at work: 6105.28 is displayed as 6105.3, while the total adds the unrounded values (16497.78, printed as 16497.8) — rounding once, at the end.
- Change one thing at a time, running
npm run typecheckafter each: deletesourcefrom the second reading (no error — it is optional); makeregionoptional (an error insidedescribe, because a template literal cannot interpolatestring | undefinedwithout a fallback); calltotalKwh(reading.kwh, 1)with the arguments swapped (no error, because both are numbers — write one sentence on what would have prevented that, using only this lesson).
Key takeaways
- A function signature is a contract: parameters and the return type are the boundary where you write types; the body is where you let the compiler infer them.
- Prefer a single destructured object parameter as soon as a function takes more than three arguments — it removes argument-order bugs and documents every call site.
- Optional parameters are
T | undefinedinside the body, default values remove that obligation, and an optional parameter must come last. constby default,letonly for real reassignment, nevervar; reach for overloads only when the return type depends on the input, otherwise a union or a generic.
Check yourself
- You need a callback that reads a property of the object it is called on. Why can an arrow function not do the job, and what does the compiler infer about
thisinside it? totalKwh(1, 5_412.5)andtotalKwh(5_412.5, 1)both type-check and mean different things. Which design change removes that trap?- Why does an optional
region?: stringproduce an error insidedescribewhen it produced none at the call site? - An async function's body says
return 18.4. What is the type of the call expression, and why is annotating the resolved value separately a mistake?