Functions & Interfaces
Master TypeScript functions with strong typing, overloading, and interfaces for defining object shapes and contracts.
TypeScript Functions & Interfaces
Master TypeScript functions and interfaces with free flashcards and spaced repetition practice. This lesson covers function types, interface declarations, optional and default parameters, generics in functions, and interface extensionsβessential concepts for building type-safe applications in TypeScript.
Welcome to Functions & Interfaces π»
Functions and interfaces are the building blocks of TypeScript development. While JavaScript gives you functions, TypeScript transforms them into powerful, type-safe constructs that catch errors before runtime. Interfaces define contracts for your code, ensuring objects have the expected shape, while function types guarantee that your functions receive and return the correct data types.
Think of interfaces as blueprints for a building ποΈβthey specify what must exist without dictating how it's built. Functions are the workers that follow these blueprints, performing tasks with predictable inputs and outputs.
Core Concepts
Function Type Annotations π
Function types in TypeScript specify both parameter types and return types. This ensures that functions are called correctly and return expected values.
// Basic function with type annotations
function add(a: number, b: number): number {
return a + b;
}
// Arrow function with types
const multiply = (x: number, y: number): number => x * y;
// Function type as a variable type
let calculate: (a: number, b: number) => number;
calculate = add; // Valid
calculate = multiply; // Valid
The syntax (param: Type) => ReturnType defines a function type signature. This is different from the arrow function syntaxβit's purely a type declaration.
π‘ Tip: Always annotate return types explicitly, even when TypeScript can infer them. This prevents accidental return type changes when refactoring.
Optional and Default Parameters β
Optional parameters are marked with ? and may be undefined. Default parameters provide fallback values.
// Optional parameter
function greet(name: string, title?: string): string {
if (title) {
return `Hello, ${title} ${name}`;
}
return `Hello, ${name}`;
}
greet("Alice"); // Valid
greet("Bob", "Dr."); // Valid
// Default parameter
function createUser(name: string, role: string = "guest"): object {
return { name, role };
}
createUser("Charlie"); // { name: "Charlie", role: "guest" }
createUser("Dana", "admin"); // { name: "Dana", role: "admin" }
β οΈ Important: Optional parameters must come after required parameters. Default parameters can appear anywhere but affect the function signature.
Interface Declarations π―
Interfaces define the shape of objects. They specify which properties must exist and their types.
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
const user: User = {
id: 1,
username: "johndoe",
email: "john@example.com",
isActive: true
};
Interfaces can also describe function signatures:
interface MathOperation {
(a: number, b: number): number;
}
const subtract: MathOperation = (x, y) => x - y;
const divide: MathOperation = (x, y) => x / y;
π§ Memory Device: "Interface = Intent" - interfaces declare your intent for what an object should contain, not how it's implemented.
Optional Properties in Interfaces π§
Interfaces can have optional properties using the ? modifier:
interface Product {
id: number;
name: string;
price: number;
description?: string; // Optional
inStock?: boolean; // Optional
}
const laptop: Product = {
id: 101,
name: "Laptop",
price: 999
// description and inStock can be omitted
};
Readonly Properties π
The readonly modifier prevents property modification after creation:
interface Config {
readonly apiKey: string;
readonly maxRetries: number;
timeout?: number;
}
const config: Config = {
apiKey: "abc123",
maxRetries: 3
};
// config.apiKey = "xyz789"; // Error! Cannot assign to 'apiKey'
Interface Extension π³
Interface extension allows interfaces to inherit properties from other interfaces using the extends keyword:
interface Animal {
name: string;
age: number;
}
interface Dog extends Animal {
breed: string;
bark(): void;
}
const myDog: Dog = {
name: "Rex",
age: 5,
breed: "Golden Retriever",
bark() {
console.log("Woof!");
}
};
You can extend multiple interfaces:
interface Printable {
print(): void;
}
interface Savable {
save(): void;
}
interface Document extends Printable, Savable {
title: string;
content: string;
}
Function Generics π
Generics allow functions to work with multiple types while maintaining type safety:
// Generic function
function identity<T>(arg: T): T {
return arg;
}
const num = identity<number>(42); // num is number
const str = identity<string>("hello"); // str is string
// Generic with array
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNum = getFirstElement([1, 2, 3]); // number | undefined
const firstStr = getFirstElement(["a", "b"]); // string | undefined
π‘ Tip: TypeScript can often infer the generic type, so you can call identity(42) without explicitly writing identity<number>(42).
Generic Interfaces π
Interfaces can also be generic:
interface Box<T> {
value: T;
getValue(): T;
}
const numberBox: Box<number> = {
value: 100,
getValue() {
return this.value;
}
};
const stringBox: Box<string> = {
value: "hello",
getValue() {
return this.value;
}
};
Function Overloads π
Function overloading allows multiple function signatures for the same function:
function format(value: string): string;
function format(value: number): string;
function format(value: boolean): string;
function format(value: string | number | boolean): string {
return String(value);
}
const s1 = format("hello"); // Valid
const s2 = format(42); // Valid
const s3 = format(true); // Valid
The implementation signature (the last one) must be compatible with all overload signatures.
Detailed Examples
Example 1: Building a Type-Safe API Client π
Let's create an interface-based API client for a user management system:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
email: string;
}
interface CreateUserRequest {
name: string;
email: string;
password: string;
}
interface ApiClient {
getUser(id: number): Promise<ApiResponse<User>>;
createUser(request: CreateUserRequest): Promise<ApiResponse<User>>;
deleteUser(id: number): Promise<ApiResponse<void>>;
}
class UserApiClient implements ApiClient {
async getUser(id: number): Promise<ApiResponse<User>> {
// Simulated API call
return {
data: { id, name: "John Doe", email: "john@example.com" },
status: 200,
message: "Success"
};
}
async createUser(request: CreateUserRequest): Promise<ApiResponse<User>> {
// Simulated API call
return {
data: { id: 1, name: request.name, email: request.email },
status: 201,
message: "User created"
};
}
async deleteUser(id: number): Promise<ApiResponse<void>> {
return {
data: undefined as void,
status: 204,
message: "User deleted"
};
}
}
Why this works: The ApiResponse<T> generic interface allows us to reuse the same response structure for different data types. The ApiClient interface ensures any implementation provides all required methods with correct signatures.
Example 2: Array Utility Functions with Constraints π οΈ
Generic functions can have type constraints using the extends keyword:
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(item: T): T {
console.log(`Length: ${item.length}`);
return item;
}
logLength("hello"); // Valid - string has length
logLength([1, 2, 3]); // Valid - array has length
logLength({ length: 10, value: "test" }); // Valid - object has length
// logLength(42); // Error! number doesn't have length property
function findMax<T extends { value: number }>(items: T[]): T | null {
if (items.length === 0) return null;
let max = items[0];
for (const item of items) {
if (item.value > max.value) {
max = item;
}
}
return max;
}
const scores = [
{ value: 85, name: "Alice" },
{ value: 92, name: "Bob" },
{ value: 78, name: "Charlie" }
];
const highest = findMax(scores); // { value: 92, name: "Bob" }
Key concept: The constraint T extends Lengthwise means "T must have at least the properties defined in Lengthwise." This prevents calling the function with incompatible types.
Example 3: Event Handler System πͺ
A practical example using function types and interfaces:
type EventCallback<T> = (data: T) => void;
interface EventEmitter<T> {
on(event: string, callback: EventCallback<T>): void;
emit(event: string, data: T): void;
off(event: string, callback: EventCallback<T>): void;
}
class SimpleEventEmitter<T> implements EventEmitter<T> {
private listeners: Map<string, EventCallback<T>[]> = new Map();
on(event: string, callback: EventCallback<T>): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
emit(event: string, data: T): void {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
off(event: string, callback: EventCallback<T>): void {
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
}
// Usage
interface UserLoginEvent {
userId: number;
timestamp: Date;
}
const userEvents = new SimpleEventEmitter<UserLoginEvent>();
userEvents.on("login", (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
userEvents.emit("login", { userId: 42, timestamp: new Date() });
What makes this powerful: The generic EventEmitter<T> interface ensures type safetyβyou can't emit data of the wrong type for your event system.
Example 4: Database Query Builder ποΈ
Combining multiple concepts for a practical use case:
interface QueryBuilder<T> {
where(field: keyof T, value: any): QueryBuilder<T>;
orderBy(field: keyof T, direction?: "asc" | "desc"): QueryBuilder<T>;
limit(count: number): QueryBuilder<T>;
execute(): Promise<T[]>;
}
interface Product {
id: number;
name: string;
price: number;
category: string;
}
class ProductQueryBuilder implements QueryBuilder<Product> {
private conditions: any[] = [];
private sortField?: keyof Product;
private sortDirection: "asc" | "desc" = "asc";
private limitCount?: number;
where(field: keyof Product, value: any): QueryBuilder<Product> {
this.conditions.push({ field, value });
return this; // Method chaining
}
orderBy(field: keyof Product, direction: "asc" | "desc" = "asc"): QueryBuilder<Product> {
this.sortField = field;
this.sortDirection = direction;
return this;
}
limit(count: number): QueryBuilder<Product> {
this.limitCount = count;
return this;
}
async execute(): Promise<Product[]> {
// Simulated database query
console.log("Executing query with:", {
conditions: this.conditions,
sort: this.sortField,
direction: this.sortDirection,
limit: this.limitCount
});
return [];
}
}
// Usage with full type safety
const query = new ProductQueryBuilder();
const products = await query
.where("category", "electronics")
.where("price", 1000)
.orderBy("name", "asc")
.limit(10)
.execute();
Why keyof T matters: Using keyof Product ensures you can only query by fields that actually exist on the Product interface. TypeScript will catch typos at compile time!
Common Mistakes β οΈ
Mistake 1: Forgetting Optional Parameter Order
// β WRONG - optional before required
function createAccount(username?: string, password: string) {
// Error! Required parameter cannot follow optional parameter
}
// β
CORRECT - optional after required
function createAccount(password: string, username?: string) {
// Valid
}
Mistake 2: Not Matching Interface Properties Exactly
interface Config {
apiKey: string;
timeout: number;
}
// β WRONG - missing required property
const config: Config = {
apiKey: "abc123"
// Error! Property 'timeout' is missing
};
// β WRONG - extra property
const config2: Config = {
apiKey: "abc123",
timeout: 5000,
retries: 3 // Error! 'retries' does not exist in type 'Config'
};
// β
CORRECT
const config3: Config = {
apiKey: "abc123",
timeout: 5000
};
Mistake 3: Modifying Readonly Properties
interface Point {
readonly x: number;
readonly y: number;
}
const point: Point = { x: 10, y: 20 };
// β WRONG
point.x = 30; // Error! Cannot assign to 'x' because it is a read-only property
// β
CORRECT - create new object
const newPoint: Point = { x: 30, y: point.y };
Mistake 4: Generic Type Inference Confusion
function combine<T>(arr1: T[], arr2: T[]): T[] {
return [...arr1, ...arr2];
}
// β WRONG - type mismatch
const mixed = combine([1, 2], ["a", "b"]);
// Error! Type 'string' is not assignable to type 'number'
// β
CORRECT - explicit union type
const mixed = combine<number | string>([1, 2], ["a", "b"]);
// β
CORRECT - same types
const numbers = combine([1, 2], [3, 4]);
Mistake 5: Missing Implementation Methods
interface Shape {
area(): number;
perimeter(): number;
}
// β WRONG - incomplete implementation
class Circle implements Shape {
constructor(private radius: number) {}
area(): number {
return Math.PI * this.radius ** 2;
}
// Error! Class 'Circle' incorrectly implements interface 'Shape'
// Property 'perimeter' is missing
}
// β
CORRECT
class Circle implements Shape {
constructor(private radius: number) {}
area(): number {
return Math.PI * this.radius ** 2;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
}
Key Takeaways π―
- Type annotations on functions prevent runtime errors by catching type mismatches at compile time
- Optional parameters (marked with
?) allow flexibility while maintaining type safety - Interfaces define contracts that objects must fulfill, ensuring consistent structure
- Readonly properties protect data from accidental modification
- Interface extension promotes code reuse through inheritance
- Generics enable type-safe, reusable functions and interfaces that work with multiple types
- Function overloads provide multiple call signatures for the same function
- Type constraints (
extends) limit generic types to those with specific properties - Always explicitly annotate return types for better maintainability
- Use
keyof Tto ensure property names are valid at compile time
π€ Did You Know?
TypeScript's interface system uses structural typing (also called "duck typing"), not nominal typing. This means two interfaces with identical properties are considered compatible, even if they have different names. If it looks like a duck and quacks like a duck, TypeScript treats it as a duck! π¦
π Further Study
- TypeScript Handbook: Functions - Official documentation on function types and signatures
- TypeScript Handbook: Interfaces - Comprehensive guide to interfaces and object types
- TypeScript Deep Dive: Generics - Advanced guide to generic types and constraints
π Quick Reference Card
| Function Type | (param: Type) => ReturnType |
| Optional Parameter | param?: Type |
| Default Parameter | param: Type = defaultValue |
| Interface | interface Name { prop: Type; } |
| Optional Property | prop?: Type |
| Readonly Property | readonly prop: Type |
| Interface Extension | interface Child extends Parent { } |
| Generic Function | function name<T>(param: T): T { } |
| Generic Interface | interface Name<T> { prop: T; } |
| Type Constraint | <T extends Interface> |
| keyof Operator | keyof T - union of property names |