Functions and Generics
Functions are where types earn their keep — a typed signature is a contract that both the caller and the implementation must honor.
Typing Parameters and Returns
Parameters get annotations; the return type goes after the parameter list:
function applyDiscount(price: number, percent: number): number {
return price - (price * percent) / 100;
}
applyDiscount(1000, 10); // ✅ 900
applyDiscount(1000, "10"); // ❌ Argument of type 'string' is not assignable
applyDiscount(1000); // ❌ Expected 2 arguments, but got 1
TypeScript can infer return types, but I annotate them on anything exported or non-trivial. The annotation catches bugs inside the function:
function getStatusLabel(code: number): string {
if (code === 200) return "OK";
if (code === 404) return "Not Found";
// ❌ Function lacks ending return statement...
// Forgot the fallback — the annotation caught it.
}
Arrow functions work the same way:
const applyTax = (amount: number, rate: number): number =>
amount + amount * rate;
Functions that return nothing use void:
function logRequest(method: string, path: string): void {
console.log(`${method} ${path}`);
}
Optional and Default Parameters
A ? makes a parameter optional; a default value makes it optional and gives it a fallback:
function buildUrl(path: string, query?: string): string {
return query ? `${path}?${query}` : path;
}
buildUrl("/users"); // "/users"
buildUrl("/users", "page=2"); // "/users?page=2"
function paginate(items: string[], pageSize: number = 10): string[] {
return items.slice(0, pageSize);
}
paginate(["a", "b", "c"]); // uses pageSize 10
paginate(["a", "b", "c"], 2); // ["a", "b"]
Inside buildUrl, the type of query is "string or undefined" — TypeScript makes you handle the missing case. With a default value there's nothing to handle: pageSize is always a number.
Function Types
Functions are values, so they have types too. This matters the moment you pass callbacks around:
type Validator = (value: string) => boolean;
const isEmail: Validator = (value) => value.includes("@");
const isNotEmpty: Validator = (value) => value.trim().length > 0;
function validateField(value: string, validators: Validator[]): boolean {
return validators.every((check) => check(value));
}
validateField("rizwan@example.com", [isNotEmpty, isEmail]); // true
Notice the arrow functions don't annotate value — the Validator type already says it's a string, so TypeScript infers it. Named function types like this are everywhere in real codebases: event handlers, middleware, comparators.
Generics
Here's the problem generics solve. Say you want a function that returns the first element of an array:
function firstOfStrings(arr: string[]): string | undefined {
return arr[0];
}
function firstOfNumbers(arr: number[]): number | undefined {
return arr[0];
}
// ...one copy per type? No thanks.
You could accept any[], but then the return type is any and you've thrown away type safety. Generics let the function work for any type while keeping track of which one:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([10, 20, 30]); // n is number | undefined
const s = first(["a", "b", "c"]); // s is string | undefined
T is a type parameter — a placeholder filled in at each call site. You never wrote first<number> explicitly; TypeScript inferred it from the argument.
Real use case: a typed API response wrapper
Every backend I've built wraps responses in a standard envelope. Generics make one interface serve every endpoint:
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
interface User {
id: number;
name: string;
}
interface Product {
id: number;
title: string;
price: number;
}
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<ApiResponse<User>>;
}
async function fetchProducts(): Promise<ApiResponse<Product[]>> {
const res = await fetch("/api/products");
return res.json() as Promise<ApiResponse<Product[]>>;
}
const userRes = await fetchUser(1);
console.log(userRes.data.name); // ✅ typed as string
const productRes = await fetchProducts();
console.log(productRes.data[0].price); // ✅ typed as number
One ApiResponse definition, and data is correctly typed for every endpoint. Without generics you'd either duplicate the envelope per resource or fall back to any.
Real use case: array helpers
Generic helpers preserve types through transformations:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const users = [
{ id: 1, name: "Rizwan" },
{ id: 2, name: "Sara" },
];
const names = pluck(users, "name"); // string[]
const ids = pluck(users, "id"); // number[]
pluck(users, "email"); // ❌ '"email"' is not a key of the user objects
Generic Constraints
Sometimes "any type" is too loose — your function needs the type to have something. extends constrains what T can be:
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
const orders = [
{ id: 1, total: 250 },
{ id: 2, total: 990 },
];
const order = findById(orders, 2); // ✅ typed as the full order object
console.log(order?.total); // 990
findById(["a", "b"], 1); // ❌ string has no 'id' property
Inside the function, T is guaranteed to have an id of type number, so item.id is safe. But the return type is still the full T — callers keep every property, not just id. That's the trick constraints give you: require what you need, preserve everything else.
Recap
- Annotate parameters always; annotate return types on exported functions
?and default values make parameters optional- Name function types with a type alias for callbacks and handlers
- Generics keep type information flowing through reusable code — the API wrapper pattern alone justifies them
- Constraints (
T extends Something) require capabilities without giving up the concrete type
Last stop in the basics: putting all of this to work in real Node and React projects.