JavaScript and TypeScriptFrom definition to callRead it, run it, reuse it
Functions
Unpacked
Parameters are the names a function promises to fill. Arguments are the values you fill them with. Start from that one idea and work through arrow functions, IIFEs, every way to invoke a function, typed parameters and rest arguments, ending with a pipeline you can run.
Params vs args
You will be able to point at any function and say which names are parameters and which values are arguments.
A parameter is a name in the function definition. It is a placeholder the function promises to fill. An argument is the actual value you pass when you call the function. Think of a form: the labels on the form are parameters, and what you write in the boxes are arguments.
function greet(name) {}Declared once, in the definition.greet("Shree")Supplied every time you call it.// parameters: the names written in the definition
function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}!`;
}
// arguments: the values you hand over at the call site
console.log(greet("Shree")); // one argument, the default fills the rest
console.log(greet("Advik", "Namaste")); // two arguments
console.log(greet.length); // counts parameters before the first default
Why it matters: a default parameter only kicks in when the argument is missing or undefined, and fn.length tells you how many arguments a function expects up front.
$ npx tsx greet.ts Hello, Shree! Namaste, Advik! 1
JavaScript never checks the count. Missing arguments arrive as undefined and extra ones are dropped. Objects are passed by sharing, so a function can change an object you gave it, but reassigning a parameter only changes its local name.
function pair(a, b) {
return [a, b];
}
console.log(pair(1)); // missing argument becomes undefined
console.log(pair(1, 2, 3)); // the extra argument is silently ignored
function rename(user, label) {
user.name = label; // mutates the object the caller also holds
label = "changed"; // reassigning a parameter never reaches the caller
}
const account = { name: "old" };
let tag = "new";
rename(account, tag);
console.log(account.name, tag);
Watch for this: mutating an argument leaks out of the function, reassigning one does not. TypeScript catches the wrong argument count at compile time, plain JavaScript does not.
$ node arity.js [ 1, undefined ] [ 1, 2 ] new new
Kinds of functions
You will know the six everyday ways to write a function and when an arrow function is the right choice.
// 1. declaration: hoisted, has its own this and arguments
function add(a: number, b: number) { return a + b; }
// 2. expression: a function stored in a variable
const sub = function (a: number, b: number) { return a - b; };
// 3. arrow: short, no own this, no arguments object
const mul = (a: number, b: number) => a * b;
// 4. method shorthand inside an object
const math = { div(a: number, b: number) { return a / b; } };
// 5. async and 6. generator
const later = async (x: number) => x * 10;
function* range(n: number) { for (let i = 0; i < n; i++) yield i; }
console.log(add(2, 3), sub(9, 4), mul(3, 3), math.div(8, 2));
later(4).then((v) => console.log(v, [...range(4)]));
Read it as: the body can be the same in every form. What changes is hoisting, how this is bound, and whether you can call it with new.
$ npx tsx kinds.ts 5 5 9 4 40 [ 0, 1, 2, 3 ]
| Kind | Syntax | Hoisted | Own this | Works with new |
|---|---|---|---|---|
| Declaration | function f() {} | Yes | Yes | Yes |
| Expression | const f = function () {} | No | Yes | Yes |
| Arrow | const f = () => {} | No | No borrows outer | No |
| Method | { f() {} } | n/a | Yes | No |
| Async | async function f() {} | as its form | Yes | No |
| Generator | function* f() {} | as its form | Yes | No |
| Class | class F { constructor() {} } | No | Yes | Required |
Arrow functions up close
Arrow functions drop the function keyword, return a single expression without return, and never create their own this or arguments. Wrap an object literal in parentheses, or the braces are read as a function body.
console.log(area(3)); // works: declarations are hoisted
// console.log(cube(3)); // ReferenceError: Cannot access 'cube' before initialization
function area(r) { return Math.round(Math.PI * r * r); }
const cube = (n) => n ** 3;
// arrow bodies: implicit return, and parentheses around an object literal
const toUser = (id, name) => ({ id, name });
console.log(toUser(7, "Ravi"));
Gotcha: const and let functions sit in the temporal dead zone until their line runs, while declarations are usable from the top of the file.
$ node hoisting.js 28 { id: 7, name: 'Ravi' }
const timer = {
ticks: 0,
withArrow() {
setTimeout(() => {
this.ticks++; // arrow borrows this from withArrow
console.log("arrow sees", this.ticks);
}, 0);
},
withClassic() {
setTimeout(function () {
console.log("classic sees", this?.ticks); // this is no longer timer
}, 0);
},
};
timer.withArrow();
timer.withClassic();
Rule of thumb: use arrows for callbacks inside methods so this stays put, and use methods or declarations when the function itself needs a this.
$ node arrow-this.js arrow sees 1 classic sees undefined
Self invoked functions
You will be able to write an IIFE in every common shape and know the two jobs it still does well.
An immediately invoked function expression, or IIFE, is defined and called in the same breath. The first pair of parentheses turns the function into an expression, and the second pair calls it, with any arguments you want to pass.
// classic IIFE, with an argument passed straight in
(function (env) {
console.log(`booting in ${env}`);
})("production");
// arrow IIFE
(() => console.log("arrow IIFE ran"))();
// module pattern: private state, public API
const ids = (() => {
let next = 0;
return { take: () => ++next, peek: () => next };
})();
console.log(ids.take(), ids.take(), ids.peek());
console.log(typeof next); // next never leaked
Why it matters: everything declared inside stays private. The returned object is the only door in, which is the classic module pattern that came before ES modules.
$ node iife.js booting in production arrow IIFE ran 1 2 2 undefined
const sleep = (ms: number) => new Promise<void>((done) => setTimeout(done, ms));
// async IIFE: await without top-level await support
(async () => {
console.log("connecting...");
await sleep(50);
console.log("connected after 50ms");
})();
// a leading semicolon guards against the previous line
;(function () { console.log("safe even after a line with no semicolon"); })();
Order of output: the async IIFE pauses at await, so the next IIFE logs first. The leading semicolon protects you when the line above has none.
$ npx tsx async-iife.ts connecting... safe even after a line with no semicolon connected after 50ms
Calling functions
You will recognise every way a function gets invoked and predict what this will be in each.
How you call a function decides what this is. The function body never changes, only the call site does.
| Pattern | Call site | What this is |
|---|---|---|
| Function call | fn(a) | undefined in strict mode |
| Method call | obj.fn(a) | the object before the dot |
| call | fn.call(ctx, a, b) | ctx, arguments listed one by one |
| apply | fn.apply(ctx, [a, b]) | ctx, arguments as one array |
| bind | fn.bind(ctx, a)() | ctx, fixed forever on the new function |
| Constructor | new Fn(a) | the brand new object |
"use strict";
function whoAmI(greeting = "hi") {
return `${greeting} from ${this?.name ?? "nobody"}`;
}
const svc = { name: "orders", whoAmI };
console.log(whoAmI()); // function call
console.log(svc.whoAmI()); // method call
console.log(whoAmI.call({ name: "billing" }, "hey")); // call: args one by one
console.log(whoAmI.apply({ name: "auth" }, ["yo"])); // apply: args as an array
const bound = whoAmI.bind({ name: "search" }, "bound");
console.log(bound()); // bind: a new function, this fixed
Why it matters: bind can also lock in leading arguments, which is called partial application.
$ node invoke.js hi from nobody hi from orders hey from billing yo from auth bound from search
class Queue {
constructor(public name: string, public size = 0) {}
}
const q = new Queue("emails"); // constructor invocation
console.log(q);
const double = (n: number) => n * 2;
console.log([1, 2, 3].map(double)); // pass the function, do not call it
// [1, 2, 3].map(double()); // Error: expected 1 argument, got 0
const hooks: { onDone?: (ms: number) => void } = {};
hooks.onDone?.(120); // optional call: skipped, no crash
hooks.onDone = (ms) => console.log(`done in ${ms}ms`);
hooks.onDone?.(120);
Pass or call: map(double) hands the function over to be called later. map(double()) calls it right now. Optional call ?.() runs a function only if it exists.
$ npx tsx call-site.ts Queue { name: 'emails', size: 0 } [ 2, 4, 6 ] done in 120ms
Typed parameters
You will type parameters and return values in TypeScript, including optional, default, literal, generic and overloaded signatures.
TypeScript adds a type after each parameter with a colon. A ? makes it optional, a default value makes it optional and infers the type, and the return type goes after the closing parenthesis.
type Level = "debug" | "info" | "error";
function log(message: string, level: Level = "info", meta?: Record<string, unknown>): void {
const extra = meta ? ` ${JSON.stringify(meta)}` : "";
console.log(`[${level.toUpperCase()}] ${message}${extra}`);
}
log("server started"); // default level
log("cache miss", "debug", { key: "user:42" }); // optional meta supplied
// log("oops", "fatal"); // Error: '"fatal"' is not assignable to type 'Level'
Why it matters: a union of string literals turns a typo like "fatal" into a compile error instead of a production log you never find.
$ npx tsx logger.ts [INFO] server started [DEBUG] cache miss {"key":"user:42"}
// a function type alias with a generic payload
type Handler<T> = (payload: T) => void;
const onUser: Handler<{ id: number; name: string }> = (u) => console.log(`hello ${u.name}`);
onUser({ id: 1, name: "Shree" });
// generic parameter: the return type follows the argument
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
const n = first([10, 20]); // n: number | undefined
const s = first(["a", "b"]); // s: string | undefined
console.log(n, s);
Read it as: the generic T is a parameter for types. The argument you pass decides what T becomes, so the return type follows along for free.
$ npx tsx generics.ts hello Shree 10 a
// overloads: two call shapes, one implementation
function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] {
return Array.isArray(input) ? input.map(Number) : Number(input);
}
const one = parse("42"); // one: number
const many = parse(["1", "2"]); // many: number[]
console.log(one + 1, many);
When to reach for it: overloads describe distinct call shapes to the caller. The last, wider signature is the implementation and callers never see it.
$ npx tsx overloads.ts 43 [ 1, 2 ]
Many arguments
You will collect any number of arguments with rest, spread arrays into calls and swap long parameter lists for one options object.
Three dots do two opposite jobs. In a definition, ...args is a rest parameter that gathers the remaining arguments into a real array. At a call site, ...list is a spread that unpacks an array into separate arguments.
// old way: arguments is array-like, not a real array
function oldSum() {
return Array.prototype.reduce.call(arguments, (a, b) => a + b, 0);
}
// modern way: a rest parameter is a real array
const sum = (...nums) => nums.reduce((a, b) => a + b, 0);
console.log(oldSum(1, 2, 3), sum(1, 2, 3, 4));
console.log(sum()); // zero arguments is fine, nums is []
Prefer rest: the old arguments object has no array methods and does not exist inside arrow functions. A rest parameter fixes both.
$ node sum.js 6 10 0
// fixed parameters first, the rest collects everything after
function tagAll(prefix: string, ...tags: string[]): string[] {
return tags.map((t) => `${prefix}:${t}`);
}
console.log(tagAll("env", "dev", "qa", "prod"));
// tuple typed rest: a fixed shape with an optional last slot
function move(...[x, y, label]: [number, number, string?]): string {
return `${label ?? "point"} -> (${x}, ${y})`;
}
console.log(move(3, 4), "|", move(0, 0, "origin"));
Typed rest: ...tags: string[] accepts any number of strings. A tuple type like [number, number, string?] gives each slot its own type and position.
$ npx tsx rest.ts [ 'env:dev', 'env:qa', 'env:prod' ] point -> (3, 4) | origin -> (0, 0)
// spread at the call site: an array becomes separate arguments
const scores = [71, 94, 88];
console.log(Math.max(...scores));
const coords: [number, number] = [3, 4];
console.log(Math.hypot(...coords));
// many settings? take one options object and destructure it
interface RequestOpts { url: string; retries?: number; timeoutMs?: number }
function request({ url, retries = 2, timeoutMs = 1000 }: RequestOpts): string {
return `${url} retries=${retries} timeout=${timeoutMs}ms`;
}
console.log(request({ url: "/api/users", timeoutMs: 500 }));
Rule of thumb: past three parameters, take one options object. Callers name every argument, order stops mattering and defaults live in the destructuring.
$ npx tsx spread.ts 94 5 /api/users retries=2 timeout=500ms
Full example
You will run a small text pipeline that uses rest parameters, generics, arrows, a higher order function and an async IIFE together.
This file turns article titles into URL slugs. Each step is an arrow function with a typed parameter, pipeline takes them all as a rest parameter, and an async IIFE runs the job. Save it, run it with Node 18 or newer, and you should see the output below.
type Step<T> = (input: T) => T | Promise<T>;
// rest parameter: any number of typed steps
function pipeline<T>(...steps: Step<T>[]) {
return async (input: T): Promise<T> => {
let value = input;
for (const step of steps) value = await step(value);
return value;
};
}
// arrow functions as small, typed steps
const trim: Step<string> = (s) => s.trim();
const lower: Step<string> = (s) => s.toLowerCase();
const slug: Step<string> = (s) => s.replace(/[^a-z0-9]+/g, "-");
// higher order: returns a step, with a default parameter
const withSuffix = (suffix = "-v1"): Step<string> => (s) => `${s}${suffix}`;
// async IIFE wires everything together
(async () => {
const toSlug = pipeline(trim, lower, slug, withSuffix());
const titles = [" Function Args vs Params ", "Arrow Functions 101"];
const slugs = await Promise.all(titles.map((t) => toSlug(t)));
console.log(slugs);
console.log(`steps used: ${[trim, lower, slug].length + 1}`);
})();
What each part teaches: ...steps is rest, Step<T> is a generic function type, withSuffix() returns a function with a default parameter, and titles.map((t) => toSlug(t)) passes one argument explicitly so map cannot sneak its index in as a second.
$ npx tsx pipeline.ts [ 'function-args-vs-params-v1', 'arrow-functions-101-v1' ] steps used: 4
# no install needed, tsx runs TypeScript directly $ npx tsx pipeline.ts # prefer compiled output? type check, then run the JavaScript $ npx tsc pipeline.ts --target es2022 --strict && node pipeline.js
Quick recall
Every term from this guide on one screen, for the next time you review a pull request.
| Term | What it is | Example |
|---|---|---|
| Parameter | A name in the definition | function f(x) {} |
| Argument | A value at the call site | f(42) |
| Default parameter | Used when the argument is undefined | (n = 10) => n |
| Optional parameter | May be left out, TypeScript only | (id?: string) => id |
| Rest parameter | Gathers the remaining arguments | (...ids: number[]) => ids |
| Spread argument | Unpacks an array into arguments | Math.max(...nums) |
| IIFE | Defined and invoked at once | (() => init())() |
| call, apply, bind | Invoke with a chosen this | f.call(obj, a) |
| Overload | Several call signatures, one body | function p(s: string): number; |