Functions & generics.
Write it once. Work with any type.
Functions are where TypeScript earns its keep. Typed signatures catch mismatched arguments before they run. Generics go further — they let one function work with any type while keeping the exact type flowing through. This lesson covers typed signatures, optional and default parameters, function overloads, and then generics from the ground up: the syntax, inference, constraints, and the patterns that show up in every library you'll ever read. This is the lesson where TypeScript clicks.
Annotate function parameters — they have no initial value, so inference can't help. Return types are inferred, but write them on exported functions. Generics use to preserve type info through a transformation: function first returns the same type as the array elements. Constraints restrict generics: lets you access .length inside the function. Generics are the difference between "works with any type" (any) and "works with any type, keeps type safety" ().
Before this lesson: complete Lesson 2 — you'll use interfaces as function parameters throughout.
📐 Function signatures — the basics, done right
Parameters need annotations. Return types are inferred. That's the rule.
A function signature is the part before the body: the name, the parameters with their types, and the return type. TypeScript checks every call against this signature — passing a string where a number is expected is a compile error, caught before the code runs.
// ─── Named function declaration ─────────────── function add(a: number, b: number): number { return a + b; } // ↑ param types ↑ return type // ─── Arrow function ─────────────────────────── const multiply = (a: number, b: number): number => a * b; // ─── Function type alias ───────────────────── type MathOp = (a: number, b: number) => number; const subtract: MathOp = (a, b) => a - b; // ↑ a and b are inferred from MathOp — no annotations needed // ─── The compiler catches wrong calls ──────── add(1, 2); // ✅ 3 // add('1', '2'); // ❌ string not assignable to number // add(1); // ❌ Expected 2 arguments, but got 1 // add(1, 2, 3); // ❌ Expected 2 arguments, but got 3 // ─── Return type is inferred, but writing it // on exported functions is best practice export function double(n: number): number { return n * 2; } console.log(add(1, 2));
// All annotations erased. The output is plain JavaScript. function add(a, b) { return a + b; } const multiply = (a, b) => a * b; const subtract = (a, b) => a - b; function double(n) { return n * 2; } console.log(add(1, 2));
3
Parameters need annotations
A parameter has no initial value to infer from. function add(a, b) makes both any. Always annotate parameters — this is the non-negotiable rule.
Return type is inferred
function add(a: number, b: number) infers : number from a + b. For local helpers, skip the return annotation. For exports, write it — it locks the API.
Function type alias
type MathOp = (a: number, b: number) => number names a signature. Assigning a function to a variable of type MathOp infers parameter types automatically — no repetition.
Arity is checked
Wrong number of arguments is a compile error, not a runtime surprise. add(1) fails to compile, not to run. That's the value — errors surface in your editor.
Annotate exports, infer internals. The rule that scales: every exported function has explicit parameter and return types; internal helpers use inference. Exports are your API — you want them locked down and visible. Internal helpers are implementation — let the compiler do its job and keep the code readable. This one principle eliminates 90% of the "should I annotate this?" decisions.
🎛️ Optional and default parameters
Two ways to make a parameter not required. They behave differently.
Optional parameters use ? — they can be omitted, and their type becomes T | undefined. Default parameters use = value — they can also be omitted, but they're never undefined inside the function. The type difference matters.
// ─── Optional parameter with ? ──────────────── function greet(name: string, greeting?: string): string { // greeting: string | undefined inside the function return ${greeting ?? 'Hello'}, ${name}!; } greet('Alice'); // ✅ uses fallback greet('Alice', 'Hi'); // ✅ uses provided greeting // greet(); // ❌ name is required // ─── Default parameter with = ───────────────── function greet2(name: string, greeting = 'Hello'): string { // greeting: string inside the function (never undefined) return ${greeting}, ${name}!; } greet2('Bob'); // ✅ 'Hello, Bob!' greet2('Bob', 'Hey'); // ✅ 'Hey, Bob!' // ─── The key difference ────────────────────── // optional: type is T | undefined, must narrow before use // default: type is T, can use directly // ─── Where it bites you ────────────────────── // Optional can't come before required: // function bad(a?: number, b: number) {} // ❌ // Default can come anywhere: function ok(a = 1, b: number) { return a + b; } console.log(greet('Alice')); console.log(greet2('Bob'));
function greet(name, greeting) { return ${greeting ?? 'Hello'}, ${name}!; } function greet2(name, greeting = 'Hello') { return ${greeting}, ${name}!; } function ok(a = 1, b) { return a + b; } console.log(greet('Alice')); console.log(greet2('Bob'));
Hello, Alice!
Hello, Bob!
? gives T | undefined
greeting?: string means inside the function it's string | undefined. You must check it before using as a string. This is intentional — the parameter really might be missing.
= value gives T
greeting = 'Hello' means inside the function it's always string. If the caller omits it, the default kicks in. No null check needed — it's never undefined.
Position matters for ?
Optional parameters must come after required ones. function f(a?: number, b: number) is a compile error — callers can't skip a and pass b positionally.
Defaults can be anywhere
Default parameters can precede required ones. function f(a = 1, b: number) is legal — the caller passes b as the first argument and a falls back to 1.
Prefer default over optional when you have a sensible fallback. greeting = 'Hello' is cleaner than greeting?: string with a null check inside. Defaults keep the type as string throughout the function body — no narrowing, no ??, no noise. Reserve ? for parameters that genuinely have no sensible default and must be handled case-by-case.
🔀 Function overloads — multiple signatures, one implementation
Useful for parsing and builders. Often replaceable with unions + guards.
An overload lets you declare the same function name with different parameter types. Callers see each specific signature and get precise inference. The implementation signature is broad enough to handle all cases, but callers never see it directly.
// ─── Overload signatures (no body) ──────────── function parse(input: string): number; function parse(input: number): string; // ─── Implementation signature (broad) ───────── function parse(input: string | number): string | number { if (typeof input === 'string') { return parseInt(input, 10); } return String(input); } // Callers get the specific return type: const a = parse('42'); // a: number const b = parse(42); // b: string // ─── The modern alternative: unions + guards ── // For most cases, this is simpler than overloads: type Parsed= T extends string ? number : string; function parse2 extends string | number>(input: T): Parsed { if (typeof input === 'string') { return parseInt(input, 10) as Parsed ; } return String(input) as Parsed ; } // ─── When overloads are actually right ──────── // DOM APIs, event handlers, and builder functions with // fundamentally different shapes per call often need overloads. // For simple "input type A → output type B" cases, // unions + generic guards are cleaner. console.log(a, b);
// Overload signatures erased. Only the implementation remains. function parse(input) { if (typeof input === 'string') { return parseInt(input, 10); } return String(input); } const a = parse('42'); const b = parse(42); function parse2(input) { if (typeof input === 'string') { return parseInt(input, 10); } return String(input); } console.log(a, b);
42 '42'
Overload signatures
The first declarations — no body, just the signature. Callers see only these. function parse(input: string): number; tells the world "string in, number out."
Implementation signature
The last declaration with a body. Its parameters must be broad enough to handle every overload. Callers don't see it — they only see the overloads above.
Return type per overload
parse('42') infers number. parse(42) infers string. Each call gets its specific return type — that's the value of overloads over a simple union.
Prefer guards when possible
Unions with type guards (lesson 4) often replace overloads. Use overloads for DOM-style APIs or genuinely different shapes. Use unions + guards for simple "A in, B out" cases.
Start with unions. Graduate to overloads only when you must. A union + type guard solves 80% of the cases people write overloads for, and it's easier to maintain. Overloads become essential when (1) the return type depends on which overload matched, (2) the parameters have genuinely different shapes (e.g. createElement(tag: string) vs createElement(component: Function)), or (3) you're typing an existing API you can't change. For new code, reach for unions first.
🧬 Generics — the superpower
One function, any type, full type safety. This is what makes TypeScript powerful.
Here's the problem generics solve. You write function first(arr) — what's the parameter type? If you say any[], the function accepts anything, but the return type is any — you lose type info. If you say number[], it only works with numbers. A generic () solves both: it says "the input is an array of some type T, and the output is a T." The type flows through.
// ─── The problem: any loses type info ───────── function firstAny(arr: any[]): any { return arr[0]; } const a = firstAny(['x', 'y']); // a: any — no autocomplete, no type checking // ─── The solution: generic──────────────── function first(arr: T[]): T | undefined { return arr[0]; } const b = first(['x', 'y']); // b: string | undefined const c = first([1, 2, 3]); // c: number | undefined const d = first([{ id: 1 }, { id: 2 }]); // d: { id: number } | undefined — full shape preserved // ─── Explicit type argument ────────────────── const e = first<string>(['x', 'y']); // explicit: string | undefined // Usually unnecessary — inference handles it // ─── Multiple type params ──────────────────── function pair(a: A, b: B): [A, B] { return [a, b]; } const p = pair('x', 1); // [string, number] // ─── Generic with transformation ───────────── function map (arr: T[], fn: (item: T) => U): U[] { return arr.map(fn); } const lengths = map(['a', 'bb', 'ccc'], s => s.length); // lengths: number[] — U inferred from the callback console.log(b, c, p, lengths);
// All generic syntax erased. Type parameters don't exist at runtime. function firstAny(arr) { return arr[0]; } const a = firstAny(['x', 'y']); function first(arr) { return arr[0]; } const b = first(['x', 'y']); const c = first([1, 2, 3]); const d = first([{ id: 1 }, { id: 2 }]); const e = first(['x', 'y']); function pair(a, b) { return [a, b]; } const p = pair('x', 1); function map(arr, fn) { return arr.map(fn); } const lengths = map(['a', 'bb', 'ccc'], s => s.length); console.log(b, c, p, lengths);
x 1 [ 'x', 1 ] [ 1, 2, 3 ]
is a type variable
Think of T as a slot for a type. The caller fills it in — first(['x']) sets T = string. Inside the function, T is whatever the caller passed.
Inference fills T
You rarely write first. TypeScript infers T from the argument. Only specify T explicitly when inference can't figure it out or you want to force a specific type.
Type flows through
The return type T is the same T as the parameter. Pass string[], get string. Pass User[], get User. The relationship is preserved — that's the whole point.
Multiple params
declares two type variables. map — T is input, U is output, both inferred from the arguments.
Never use any where a generic belongs. function first(arr: any[]): any compiles but defeats the purpose — you've written an untyped function. function first is the same length and preserves full type safety. The only legitimate use of any is interop with untyped libraries. If you're writing new code, use a generic.
🔒 Generic constraints — restricting what T can be
extends on a type parameter says "T must satisfy this."
An unconstrained can be anything — which means inside the function, you can't do much with it. T.length fails because T might not have a length. A constraint () narrows what T can be, and unlocks the properties of that type inside the function.
// ─── Without a constraint ──────────────────── function lenBad(x: T): number { // return x.length; // ❌ Property 'length' does not exist on type 'T' return 0; } // ─── With a constraint ─────────────────────── function len extends { length: number }>(x: T): number { return x.length; // ✅ T is guaranteed to have .length } len('hello'); // ✅ 5 — strings have length len([1, 2, 3]); // ✅ 3 — arrays have length // len(42); // ❌ number doesn't have length // ─── Constraint with keyof ─────────────────── function get extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: 'Alice', age: 28 }; const n = get(user, 'name'); // string const g = get(user, 'age'); // number // get(user, 'email'); // ❌ 'email' is not a key of user // ─── Constraint to a known union ───────────── type Sortable = string | number; function sort extends Sortable>(arr: T[]): T[] { return [...arr].sort(); } sort([3, 1, 2]); // ✅ // sort([{ x: 1 }]); // ❌ object not Sortable // ─── Constraint to an interface ────────────── interface HasId { id: string } function findById extends HasId>(items: T[], id: string): T | undefined { return items.find(i => i.id === id); } console.log(len('hello'), n, g);
function lenBad(x) { return 0; } function len(x) { return x.length; } len('hello'); len([1, 2, 3]); function get(obj, key) { return obj[key]; } const user = { name: 'Alice', age: 28 }; const n = get(user, 'name'); const g = get(user, 'age'); function sort(arr) { return [...arr].sort(); } sort([3, 1, 2]); function findById(items, id) { return items.find(i => i.id === id); } console.log(len('hello'), n, g);
5 Alice 28
extends on a type param
restricts T to types compatible with SomeType. Any type that satisfies the shape is allowed — you're describing a minimum requirement.
Unlocks properties
Without a constraint, you can't access any specific property on T — it could be anything. The constraint tells the compiler "T has at least these properties," which unlocks using them.
keyof constraint
K extends keyof T restricts K to the keys of T. get(user, 'name') returns string; get(user, 'email') is a compile error. This is the pattern behind every typed pick and get function.
Constraint to a union
allows only strings or numbers. Simple, readable, and often enough — unions are a powerful constraint form.
The keyof + index pattern is a superpower. function get is one of the most useful generic signatures in TypeScript. It returns the precise type of the property you access — get(user, 'name') gives string, get(user, 'age') gives number. Every typed object utility (lodash's get, Immer's drafts, React Hook Form's register) is built on this pattern. Learn it once, recognize it everywhere.
🏆 Challenge
Three tasks. Each exercises a different function/generic skill.
Task 1 — Generic utility functions
Write these three functions. Type each signature before writing the body:
1. last — returns the last element or undefined if empty
2. unique — removes duplicates (hint: new Set(arr), but return the array)
3. groupBy — groups items by a key function
For each, call the function with two different array types and hover over the result in VS Code to confirm the inferred return type. If it's not what you expect, read the compiler error and fix the signature — this is where generics become muscle memory.
Task 2 — Constraint with keyof
Build a typed pluck function:
1. Signature: pluck
2. Given an array of objects and a key, return an array of that key's values
3. Example: pluck([{ name: 'A', age: 1 }, { name: 'B', age: 2 }], 'name') returns ['A', 'B'] typed as string[]
4. Test that pluck(users, 'email') fails to compile if email isn't a key
The point: keyof + indexed access (T[K]) lets TypeScript track the exact output type through the function. This is the pattern behind _.pluck and every typed data transformation library.
Task 3 — Build the alternative to overloads
Take a function that has two overloads (e.g. format(x: string): number; format(x: number): string) and rewrite it as a single generic function using a union + conditional type. Compare the two approaches:
1. Which is easier to read?
2. Which produces better error messages when called wrong?
3. Which would you rather maintain?
There's no single right answer — sometimes overloads win (DOM-style APIs), sometimes unions + conditional types win (data transformations). The exercise is to know both and make the trade consciously. This is the kind of judgment call that separates intermediate from senior TypeScript developers.
⚠️ Common mistakes
The function and generic bugs that catch every beginner.
function add(a, b) makes both any — you've disabled type checking. Fix: always annotate parameters. This is the single most common TypeScript mistake.
any where a generic belongsfunction first(arr: any[]): any works but loses all type info. Fix: function first. Same length, full safety.
function getLength — T might not have a length. Fix: constrain. tells the compiler "T has length."
function f(a?: number, b: number) is a compile error — you can't skip a positional param and pass a later one. Fix: put required params first, or use an options object.
Three or more overloads usually means the API should be redesigned. Fix: discriminated unions, generic conditional types, or a single options object. Overloads are a tool, not a default.
Inside the implementation of an overloaded function, TypeScript doesn't narrow automatically — the parameter is the broad union. Fix: check with typeof, instanceof, or in inside the body.
first is redundant — inference already gives T = string. Fix: only specify type args when inference can't figure it out or you're forcing a wider type.
function double is wrong — n * 2 fails on T. Fix: function double(n: number). Only generic when the type genuinely varies AND you need to preserve the relationship between input and output.
❓ Frequently asked questions
How do I type a function in TypeScript?
Annotate the parameters and the return type: function add(a: number, b: number): number { return a + b; }. Parameters always need annotations — there's no initial value to infer from. Return types are inferred, but writing them on exported functions catches accidental API changes.
What are generics in TypeScript?
Generics let you write functions and types that work with any type while preserving the specific type flowing through. function identity
What is the difference between a generic and any?
any erases type information — a function returning any loses the input type at the call site. Generics preserve it: identity
What are generic constraints?
A constraint restricts what types a generic can be. function getLength
What are function overloads?
Overloads let one function name have multiple call signatures. You declare each signature without a body, then implement a general version that handles all cases. Callers see the specific signatures, the implementation handles the union. Use sparingly: discriminated unions with type guards are often cleaner.
What is the difference between function declaration and arrow function?
Both work in TypeScript. Arrow functions capture this from the surrounding scope, function declarations define their own this. Type inference works the same way. For React components prefer arrow functions. For utilities, either works.
Can generics have default types?
Yes. function create
When should I use generics?
Use generics when (1) the input type and output type are the same — identity, first, last, map. (2) you want to preserve type info across a transformation — map