skip to content
Astro Cactus

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

  1. Introduction to Types
  2. Primitive Types
  3. Complex Types
  4. Type Annotations and Inference
  5. Literal Types
  6. Union and Intersection Types
  7. Type Assertions
  8. Advanced Type Patterns
  9. 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 TypeScript
function calculateTotal(items) {
return items.reduce((total, item) => total + item.price, 0);
}
// With TypeScript
interface 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 time
calculateTotal([
{ 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:

// Number
let age: number = 25;
let price: number = 99.99;
let binary: number = 0b1010; // Binary
let octal: number = 0o744; // Octal
let hex: number = 0xf00d; // Hexadecimal
// String
let name: string = "John";
let greeting: string = `Hello ${name}`;
let multiline: string = `
This is a
multiline string
in TypeScript
`;
// Boolean
let isActive: boolean = true;
let isComplete: boolean = false;
// Null and Undefined
let nullValue: null = null;
let undefinedValue: undefined = undefined;
// Symbol
let sym1: symbol = Symbol("key");
let sym2: symbol = Symbol("key");
console.log(sym1 === sym2); // false
// BigInt
let bigNumber: bigint = 100n;
let anotherBigNumber: bigint = BigInt(100);

Complex Types

Arrays

// Simple array types
let numbers: number[] = [1, 2, 3];
let strings: Array<string> = ["hello", "world"];
// Array of objects
interface Product {
id: number;
name: string;
price: number;
}
let products: Product[] = [
{ id: 1, name: "Phone", price: 699 },
{ id: 2, name: "Tablet", price: 499 }
];
// Readonly arrays
let readonlyNumbers: ReadonlyArray<number> = [1, 2, 3];
// readonlyNumbers[0] = 4; // Error: Index signature in type 'readonly number[]' only permits reading
// Mixed type arrays with tuple
let mixed: [string, number, boolean] = ["hello", 42, true];

Objects with Index Signatures

// Dynamic object with string keys and number values
interface NumberDictionary {
[key: string]: number;
length: number; // OK, length is a number
// name: string; // Error, property must be number
}
// Dynamic object with multiple value types
interface FlexibleDictionary {
[key: string]: string | number;
id: number; // OK
name: string; // OK
// active: boolean; // Error
}
// Example usage
const 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 Types
interface 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 handling
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Usage
async 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 types
interface 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 processor
function 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;
}
}
// Usage
const 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 example
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
// Valid uses
logLength("Hello"); // string has length
logLength([1, 2, 3]); // array has length
logLength({ length: 10 }); // object with length property
// Invalid use
// logLength(123); // Error: number doesn't have length property
// Practical example: Database query builder
interface 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;
}
}
// Usage
interface 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 name

Utility Type Examples

// Practical examples of built-in utility types
// 1. Partial - Making all properties optional
interface 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);
}
// Usage
updateTask(1, { completed: true }); // Valid
updateTask(1, { title: "New title" }); // Valid
// 2. Pick - Creating a type with only selected properties
type 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 types
type 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 readonly
type 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 function
function 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 createUser
const user: User = {
id: 1,
name: "John",
age: 30,
createdAt: new Date()
};

Best Practices

  1. Use Type Inference When Possible
// Good
const 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
};
  1. Use Strict Null Checks
// Enable strict null checks in tsconfig.json
{
"compilerOptions": {
"strictNullChecks": true
}
}
// Now you must handle null/undefined explicitly
function 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");
}
  1. Use Type Guards for Runtime Safety
// Custom type guard
interface 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
}
}
  1. Use Branded Types for Type Safety
// Creating branded types for better type safety
type 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 UserId

Conclusion

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.