TypeScript · lesson 5 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Functions 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.