Understanding TypeScript Types: A Comprehensive Guide
/ 8 min read
Understanding TypeScript Types
TypeScript’s type system is one of its most powerful features, providing enhanced code quality, better tooling support, and improved developer experience. This comprehensive guide will walk you through everything you need to know about TypeScript types with practical examples.
Table of Contents
- Introduction to Types
- Primitive Types
- Complex Types
- Type Annotations and Inference
- Literal Types
- Union and Intersection Types
- Type Assertions
- Advanced Type Patterns
- Best Practices
Introduction to Types
TypeScript’s type system adds an extra layer of safety and developer productivity to JavaScript. Here’s a practical example of how types can catch errors early:
// Without TypeScriptfunction calculateTotal(items) { return items.reduce((total, item) => total + item.price, 0);}
// With TypeScriptinterface CartItem { name: string; price: number; quantity: number;}
function calculateTotal(items: CartItem[]): number { return items.reduce((total, item) => total + item.price * item.quantity, 0);}
// TypeScript will catch these errors at compile timecalculateTotal([ { name: "Book", price: "10" }, // Error: price should be number { name: "Pen", quantity: 2 } // Error: missing price property]);Primitive Types
TypeScript includes all JavaScript primitive types and adds a few additional ones:
// Numberlet age: number = 25;let price: number = 99.99;let binary: number = 0b1010; // Binarylet octal: number = 0o744; // Octallet hex: number = 0xf00d; // Hexadecimal
// Stringlet name: string = "John";let greeting: string = `Hello ${name}`;let multiline: string = ` This is a multiline string in TypeScript`;
// Booleanlet isActive: boolean = true;let isComplete: boolean = false;
// Null and Undefinedlet nullValue: null = null;let undefinedValue: undefined = undefined;
// Symbollet sym1: symbol = Symbol("key");let sym2: symbol = Symbol("key");console.log(sym1 === sym2); // false
// BigIntlet bigNumber: bigint = 100n;let anotherBigNumber: bigint = BigInt(100);Complex Types
Arrays
// Simple array typeslet numbers: number[] = [1, 2, 3];let strings: Array<string> = ["hello", "world"];
// Array of objectsinterface Product { id: number; name: string; price: number;}
let products: Product[] = [ { id: 1, name: "Phone", price: 699 }, { id: 2, name: "Tablet", price: 499 }];
// Readonly arrayslet readonlyNumbers: ReadonlyArray<number> = [1, 2, 3];// readonlyNumbers[0] = 4; // Error: Index signature in type 'readonly number[]' only permits reading
// Mixed type arrays with tuplelet mixed: [string, number, boolean] = ["hello", 42, true];Objects with Index Signatures
// Dynamic object with string keys and number valuesinterface NumberDictionary { [key: string]: number; length: number; // OK, length is a number // name: string; // Error, property must be number}
// Dynamic object with multiple value typesinterface FlexibleDictionary { [key: string]: string | number; id: number; // OK name: string; // OK // active: boolean; // Error}
// Example usageconst scores: NumberDictionary = { math: 95, science: 88, history: 92, length: 3};
const userInfo: FlexibleDictionary = { id: 1, name: "John", age: 30, email: "john@example.com"};Real-World API Response Types
// API Response Typesinterface ApiResponse<T> { data: T; status: number; message: string; timestamp: number;}
interface User { id: number; username: string; email: string; profile: { firstName: string; lastName: string; avatar: string | null; };}
// Example API response handlingasync function fetchUser(id: number): Promise<ApiResponse<User>> { const response = await fetch(`/api/users/${id}`); return response.json();}
// Usageasync function displayUser(id: number): Promise<void> { try { const result = await fetchUser(id); if (result.status === 200) { const user = result.data; console.log(`Welcome, ${user.profile.firstName}!`); } } catch (error) { console.error("Failed to fetch user"); }}Advanced Type Patterns
Discriminated Unions
// Payment method typesinterface CashPayment { type: "cash"; amount: number;}
interface CreditCardPayment { type: "credit"; cardNumber: string; amount: number; securityCode: string;}
interface BankTransferPayment { type: "transfer"; accountNumber: string; amount: number; bankCode: string;}
type Payment = CashPayment | CreditCardPayment | BankTransferPayment;
// Payment processorfunction processPayment(payment: Payment): void { switch (payment.type) { case "cash": console.log(`Processing cash payment of ${payment.amount}`); break; case "credit": console.log(`Processing credit card payment of ${payment.amount} with card ${payment.cardNumber}`); break; case "transfer": console.log(`Processing bank transfer of ${payment.amount} to account ${payment.accountNumber}`); break; }}
// Usageconst cashPayment: CashPayment = { type: "cash", amount: 100};
const creditPayment: CreditCardPayment = { type: "credit", cardNumber: "1234-5678-9012-3456", amount: 200, securityCode: "123"};
processPayment(cashPayment);processPayment(creditPayment);Generic Type Constraints
// Generic constraint exampleinterface HasLength { length: number;}
function logLength<T extends HasLength>(item: T): void { console.log(item.length);}
// Valid useslogLength("Hello"); // string has lengthlogLength([1, 2, 3]); // array has lengthlogLength({ length: 10 }); // object with length property
// Invalid use// logLength(123); // Error: number doesn't have length property
// Practical example: Database query builderinterface QueryConfig<T> { table: string; fields: (keyof T)[]; where?: Partial<T>; orderBy?: keyof T;}
class QueryBuilder<T> { constructor(private config: QueryConfig<T>) {}
build(): string { const fields = this.config.fields.join(", "); let query = `SELECT ${fields} FROM ${this.config.table}`;
if (this.config.where) { const conditions = Object.entries(this.config.where) .map(([key, value]) => `${key} = ${JSON.stringify(value)}`) .join(" AND "); query += ` WHERE ${conditions}`; }
if (this.config.orderBy) { query += ` ORDER BY ${this.config.orderBy}`; }
return query; }}
// Usageinterface User { id: number; name: string; email: string; age: number;}
const userQuery = new QueryBuilder<User>({ table: "users", fields: ["id", "name", "email"], where: { age: 25 }, orderBy: "name"});
console.log(userQuery.build());// Output: SELECT id, name, email FROM users WHERE age = 25 ORDER BY nameUtility Type Examples
// Practical examples of built-in utility types
// 1. Partial - Making all properties optionalinterface Task { id: number; title: string; description: string; completed: boolean;}
function updateTask(id: number, updates: Partial<Task>): void { // Only some properties need to be provided const task = { id: 1, title: "Original task", description: "Original description", completed: false };
Object.assign(task, updates);}
// UsageupdateTask(1, { completed: true }); // ValidupdateTask(1, { title: "New title" }); // Valid
// 2. Pick - Creating a type with only selected propertiestype TaskPreview = Pick<Task, "id" | "title">;
const previews: TaskPreview[] = [ { id: 1, title: "Task 1" }, { id: 2, title: "Task 2" }];
// 3. Record - Creating an object type with specific key and value typestype UserRoles = Record<string, "admin" | "user" | "guest">;
const userRoles: UserRoles = { "john@example.com": "admin", "jane@example.com": "user", "guest@example.com": "guest"};
// 4. Readonly - Making all properties readonlytype ImmutableTask = Readonly<Task>;
const task: ImmutableTask = { id: 1, title: "Read-only task", description: "Cannot be modified", completed: false};
// task.completed = true; // Error: Cannot assign to 'completed' because it is a read-only property
// 5. ReturnType - Extracting the return type of a functionfunction createUser(name: string, age: number) { return { id: Math.random(), name, age, createdAt: new Date() };}
type User = ReturnType<typeof createUser>;
// Now User type has the same shape as the return value of createUserconst user: User = { id: 1, name: "John", age: 30, createdAt: new Date()};Best Practices
- Use Type Inference When Possible
// Goodconst numbers = [1, 2, 3]; // Type: number[]const user = { name: "John", age: 30}; // Type: { name: string; age: number; }
// Less ideal (unnecessary annotations)const numbers: number[] = [1, 2, 3];const user: { name: string; age: number; } = { name: "John", age: 30};- Use Strict Null Checks
// Enable strict null checks in tsconfig.json{ "compilerOptions": { "strictNullChecks": true }}
// Now you must handle null/undefined explicitlyfunction getUser(id: number): User | null { // Implementation return null;}
const user = getUser(1);if (user) { console.log(user.name); // OK} else { console.log("User not found");}- Use Type Guards for Runtime Safety
// Custom type guardinterface Dog { name: string; bark(): void;}
interface Cat { name: string; meow(): void;}
function isDog(animal: Dog | Cat): animal is Dog { return 'bark' in animal;}
function makeSound(animal: Dog | Cat) { if (isDog(animal)) { animal.bark(); // TypeScript knows this is safe } else { animal.meow(); // TypeScript knows this is a Cat }}- Use Branded Types for Type Safety
// Creating branded types for better type safetytype UserId = string & { readonly brand: unique symbol };type OrderId = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId { return id as UserId;}
function createOrderId(id: string): OrderId { return id as OrderId;}
function processUser(id: UserId) { // Process user}
const userId = createUserId("user123");const orderId = createOrderId("order456");
processUser(userId); // OK// processUser(orderId); // Error: OrderId is not assignable to UserIdConclusion
TypeScript’s type system provides powerful tools for building safer, more maintainable applications. Remember to:
- Use types to catch errors early in development
- Leverage type inference when possible
- Be explicit with types when necessary for clarity
- Use union and intersection types for flexibility
- Apply type assertions judiciously
- Follow TypeScript best practices
As you become more comfortable with these basics, you can explore more advanced type features like conditional types, mapped types, and template literal types.