webdev.complete
🔵 TypeScript Basics
🛡️TypeScript
Lesson 43 of 117
25 min

Functions & Generics

Typed params, generics, NoInfer, overloads.

Functions are where TypeScript earns its keep. You annotate the edges (what goes in, what comes out), and the compiler walks the rest. Once you add generics, a single function can stay strongly typed across any shape its caller throws at it.

Typing parameters and returns

ts
// Parameter types after the colon, return type after the arrow
function add(a: number, b: number): number {
  return a + b;
}

// Arrow function with explicit return type
const greet = (name: string): string => "Hi " + name;

// Often the return type can be inferred, so leave it off and let TS figure it out
const double = (n: number) => n * 2;  // inferred as (n: number) => number, no annotation needed
Annotate the edges, not the middle
Always annotate the parameters and return types of exported functions. Let inference handle local variables and small helpers. This keeps your code readable without going Annotation Mode All The Time.

Optional, default, and rest parameters

ts
// Optional parameter: title is string | undefined
function format(name: string, title?: string) {
  return title ? title + " " + name : name;
}

// Default parameter: type is inferred from the default
function greet(name: string, greeting = "Hello") {
  return greeting + ", " + name;
}

// Rest parameter: must be an array type
function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

sum(1, 2, 3);          // 6
sum(...[10, 20, 30]);  // 60

Function types and callbacks

To describe a function as a value (e.g. a callback parameter), use the arrow syntax in a type:

ts
// A type alias for a function
type Comparator<T> = (a: T, b: T) => number;

// Inline function type as a parameter
function map<T, U>(arr: T[], fn: (item: T, index: number) => U): U[] {
  return arr.map(fn);
}

map([1, 2, 3], (n, i) => n * i);  // (n, i) are inferred as numbers

Function overloads

Sometimes a function has multiple legal call signatures. Overloads let you express that without resorting to any.

ts
// Overload signatures (just the shapes)
function parse(input: string): string;
function parse(input: number): number;

// Implementation signature (must satisfy all overloads)
function parse(input: string | number): string | number {
  if (typeof input === "string") return input.trim();
  return input * 2;
}

const a = parse("  hi  ");  // a is string
const b = parse(5);          // b is number
// parse(true); // Error: no overload matches
Overloads are a last resort
Before reaching for overloads, see if a union type or a generic gets the job done. Overloads have weird edge cases and the implementation signature is invisible to callers. Generics are almost always cleaner.

Generics: typed without losing information

A generic function takes a type as a parameter the same way a regular function takes a value. The result: one function that stays specific to whatever shape the caller passes.

ts
// Without generics, the return type forgets what went in
function identityAny(x: any): any { return x; }
const r1 = identityAny("hello");
// r1 is 'any', so we just lost the fact that it is a string

// With generics, the return type tracks the input
function identity<T>(x: T): T { return x; }
const r2 = identity("hello");  // r2 is string
const r3 = identity(42);       // r3 is number
const r4 = identity([1, 2]);   // r4 is number[]

Generic constraints

Without constraints, T could be anything (including null), so you can't use most properties on it. Use extends to require a shape.

ts
// Require T to have a 'length' property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("hello", "hi");          // OK, strings have .length
longest([1, 2, 3], [9]);         // OK, arrays have .length
// longest(5, 10);               // Error: number has no 'length'

// Constraint with a default
function paginate<T extends object = { id: string }>(items: T[]) {
  // T defaults to { id: string } if the caller does not pass it
}

Inferring from arguments

You rarely have to write out the type argument. The compiler infers it from the values you pass:

ts
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const n = first([1, 2, 3]);            // T inferred as number, n is number | undefined
const s = first(["a", "b"]);           // T inferred as string
const x = first<string>([]);           // explicit when needed

// Multi-generic inference
function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

const p = pair("hi", 42);  // p is [string, number]

NoInfer: blocking inference where you do not want it

Sometimes you want one parameter to drive the type and another to only be checked against it, not contribute to inference. The NoInfer<T> utility (TS 5.4+) does that.

ts
// Without NoInfer, the default value can widen T
function withDefault<T>(value: T, fallback: T): T {
  return value ?? fallback;
}

// If we pass two different string literals, TS widens to a union
const r = withDefault("active" as const, "inactive");
// r is "active" | "inactive", probably not what you wanted

// With NoInfer, only 'value' contributes to inferring T
function withDefault2<T>(value: T, fallback: NoInfer<T>): T {
  return value ?? fallback;
}

const r2 = withDefault2("active" as const, "active");
// r2 is exactly "active"

Generic types (not just functions)

Generics work on type aliases and interfaces too. This is how you describe containers like arrays, promises, and maps.

ts
type Box<T> = { value: T };

const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: "hi" };

interface ApiResponse<T> {
  data: T;
  error: string | null;
  status: number;
}

declare function fetchUsers(): Promise<ApiResponse<User[]>>;
const users = await fetchUsers();  // users.data is User[]

Try it

Below is a typed sandbox. Try changing the types passed to identity and findById, or adding a constraint to findById so the array element must have an id field.

// A generic identity function
function identity<T>(x: T): T {
  return x;
}

console.log(identity("hello"));   // string
console.log(identity(42));        // number
console.log(identity([1, 2, 3])); // number[]

// A generic array helper with a constraint
function findById<T extends { id: string }>(
  items: T[],
  id: string
): T | undefined {
  return items.find((item) => item.id === id);
}

type User = { id: string; name: string };

const users: User[] = [
  { id: "u_1", name: "Ada" },
  { id: "u_2", name: "Grace" },
];

const ada = findById(users, "u_1");
console.log("Found:", ada);

// Try this: it should fail to compile because Number has no 'id'
// findById([1, 2, 3], "u_1");

// A generic 'pluck' helper
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

console.log(pluck(users, "name"));  // string[]
console.log(pluck(users, "id"));    // string[]
// pluck(users, "email");           // Error: 'email' is not a key of User

The keyof trick
K extends keyof Tis one of the most useful generic patterns. It says "K must be a real key of T." The compiler then knows item[key] is T[K], the exact type of that property. Auto-complete on the second argument shows only valid keys.
Quiz1 / 4

Why is generic `identity<T>(x: T): T` better than `identity(x: any): any`?

Recap

  • Annotate the edges (parameters, return types of exported functions). Let inference handle local stuff.
  • Optional (?), default (=), and rest (...) parameters all type cleanly.
  • Generics make a function reusable without losing type info. Use extends to constrain.
  • K extends keyof T lets you index objects safely.
  • NoInfer<T> blocks a parameter from influencing inference.
  • Reach for overloads only when generics cannot express the relationship.