Learning Guides
Menu

TypeScript's Type System

11 min readEffective TypeScript

TypeScript's Type System

TypeScript's type system is sophisticated and expressive. Understanding its core concepts is essential for writing effective TypeScript code.

Item 6: Use Your Editor to Interrogate and Explore the Type System

Your editor is your most powerful tool for understanding TypeScript. Modern editors with TypeScript support provide:

  • Hover information: See inferred types by hovering over variables
  • Go to Definition: Jump to type definitions
  • Find All References: See where types are used
  • Rename Symbol: Safely rename across the codebase

Editor-Driven Development

TYPESCRIPT
// Hover over 'numbers' to see: const numbers: number[]
const numbers = [1, 2, 3, 4, 5];
 
// Hover over 'doubled' to see the inferred return type
const doubled = numbers.map(n => n \* 2);
 
// Hover over complex expressions to understand inferred types
const result = numbers
.filter(n => n > 2)
.map(n => ({ value: n, squared: n \* n }));
// result: { value: number; squared: number; }[]
 

Note

When you're unsure about a type, don't guess - hover over it in your editor. TypeScript will tell you exactly what it thinks the type is.

Item 7: Think of Types as Sets of Values

The key insight for understanding TypeScript's type system is to think of types as sets of values:

TYPESCRIPT
// The type 'number' is the set of all possible numbers
type AllNumbers = number; // Infinite set: 0, 1, -1, 3.14, ...
 
// A literal type is a set with one value
type JustOne = 1; // Set: {1}
 
// A union type is the union of sets
type OneOrTwo = 1 | 2; // Set: {1, 2}
 
// never is the empty set
type Empty = never; // Set: {}
 
// unknown is the universal set
type Everything = unknown; // All possible values

Set Operations in Types

TYPESCRIPT
// Union: Set union (OR)
type StringOrNumber = string | number;
 
// Intersection: Set intersection (AND)
type A = { a: number };
type B = { b: string };
type AandB = A & B; // { a: number; b: string }
 
// For primitives, intersection often produces never
type Impossible = string & number; // never (empty set)

Thinking in Sets

TYPESCRIPT
// Is 'Dog' assignable to 'Animal'?
// Think: Is the set of all Dogs a subset of Animals?
 
interface Animal {
name: string;
}
 
interface Dog extends Animal {
breed: string;
}
 
// Dog has more constraints → smaller set → subset of Animal
const dog: Dog = { name: 'Rex', breed: 'German Shepherd' };
const animal: Animal = dog; // ✓ Works! Dog ⊆ Animal
 

keyof and Set Operations

TYPESCRIPT
interface Person {
  name: string;
  age: number;
  email: string;
}
 
// keyof produces a union of literal types
type PersonKeys = keyof Person; // 'name' | 'age' | 'email'
 
// You can use this for type-safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const person: Person = { name: 'Alice', age: 30, email: 'alice@example.com' };
const name = getProperty(person, 'name'); // string
const age = getProperty(person, 'age');   // number
getProperty(person, 'invalid'); // Error!

Item 8: Know How to Tell Whether a Symbol Is in the Type Space or Value Space

TypeScript has two distinct spaces: type space and value space. The same name can exist in both:

TYPESCRIPT
// 'Rectangle' exists in BOTH spaces
interface Rectangle {
  width: number;
  height: number;
}
 
const Rectangle = (width: number, height: number) => ({ width, height });
 
// Type space usage
type Shape = Rectangle;
 
// Value space usage
const rect = Rectangle(10, 20);

How to Know Which Space You're In

TYPESCRIPT
// After 'type' or 'interface' → type space
type T = string;
interface I {
  x: number;
}
 
// After ':' or 'as' → type space
const x: string = "hello";
const y = x as string;
 
// After '=' → value space
const s = "hello";
 
// In angle brackets → type space
function identity<T>(arg: T): T {
  return arg;
}
 
// typeof behaves differently in each space
type PersonType = typeof person; // Type space: gets the type
const personTypeName = typeof person; // Value space: returns 'object'

Warning

Class declarations create values in both spaces. This is why you can use a class as both a type and a constructor.

TYPESCRIPT
class Point {
  constructor(
    public x: number,
    public y: number,
  ) {}
}
 
// Type space: Point is the instance type
function logPoint(point: Point) {
  console.log(point.x, point.y);
}
 
// Value space: Point is the constructor
const p = new Point(3, 4);
 
// typeof Point is the constructor type, not the instance type!
type PointConstructor = typeof Point;
// = new (x: number, y: number) => Point

Item 9: Prefer Type Declarations to Type Assertions

There are two ways to tell TypeScript what type a value has:

TYPESCRIPT
interface Person {
  name: string;
}
 
// Type declaration (preferred)
const alice: Person = { name: "Alice" };
 
// Type assertion (use sparingly)
const bob = { name: "Bob" } as Person;

Why Declarations Are Better

Declarations Catch Errors

TYPESCRIPT
interface Person {
  name: string;
  age: number;
}
 
// Declaration catches missing property
const alice: Person = { name: 'Alice' }; // Error! Missing 'age'
 
// Assertion does NOT catch it
const bob = { name: 'Bob' } as Person; // No error, but bob.age is undefined!
 
TYPESCRIPT
// Declarations also catch excess properties
const alice: Person = {
  name: 'Alice',
  age: 30,
  occupation: 'Engineer' // Error! Object literal may only specify known properties
};
 
// Assertions allow excess properties
const bob = {
  name: 'Bob',
  age: 25,
  occupation: 'Designer'
} as Person; // No error

When Assertions Are Appropriate

TYPESCRIPT
// 1. When you know more than TypeScript
document.getElementById("myButton") as HTMLButtonElement;
 
// 2. After runtime checks
function processEvent(e: Event) {
  const target = e.target as HTMLInputElement;
  console.log(target.value);
}
 
// 3. Non-null assertions (use with caution!)
const button = document.getElementById("btn")!; // Assert it's not null

Warning

Type assertions can hide bugs. They tell TypeScript "trust me, I know what I'm doing" - make sure you actually do!

Item 10: Avoid Object Wrapper Types (String, Number, Boolean)

JavaScript has both primitive types and their object wrapper counterparts:

TYPESCRIPT
// Primitives (use these)
const str: string = "hello";
const num: number = 42;
const bool: boolean = true;
 
// Object wrappers (avoid these)
const strObj: String = new String("hello");
const numObj: Number = new Number(42);
const boolObj: Boolean = new Boolean(true);

Why Avoid Wrappers?

TYPESCRIPT
// String is not assignable to string
function greet(name: string) {
  console.log("Hello, " + name);
}
 
const wrapped = new String("World");
greet(wrapped); // Error! Argument of type 'String' is not assignable to 'string'
 
// But string is assignable to String (confusing!)
const s: String = "hello"; // Works, but don't do this

Note

Always use lowercase primitive types: string, number, boolean, symbol, bigint. Never use String, Number, Boolean, Symbol, or BigInt as types.

Item 11: Recognize the Limits of Excess Property Checking

TypeScript has special behavior for object literals called "excess property checking":

TYPESCRIPT
interface Point {
  x: number;
  y: number;
}
 
// Object literal: excess property checking applies
const p1: Point = { x: 1, y: 2, z: 3 }; // Error! 'z' does not exist
 
// Variable: no excess property checking
const temp = { x: 1, y: 2, z: 3 };
const p2: Point = temp; // OK!

Why This Inconsistency?

Excess property checking is a heuristic to catch typos and mistakes in object literals. It's not part of the core type system:

TYPESCRIPT
interface Options {
  title: string;
  darkMode?: boolean;
}
 
// Catches typo in object literal
const opts: Options = {
  title: "Dashboard",
  darkmode: true, // Error! Did you mean 'darkMode'?
};
 
// But this doesn't catch the typo
const config = { title: "Dashboard", darkmode: true };
const opts2: Options = config; // No error!

Workaround for Strict Checking

TYPESCRIPT
// Use a function that enforces the type at the call site
function createOptions(opts: Options): Options {
  return opts;
}
 
// Now excess properties are caught
const config = createOptions({
title: 'Dashboard',
darkmode: true // Error!
});
 

Item 12: Apply Types to Entire Function Expressions When Possible

When you have a function type, apply it to the entire function expression, not just the parameters:

TYPESCRIPT
// Function type
type BinaryFn = (a: number, b: number) => number;
 
// Apply to entire expression (preferred)
const add: BinaryFn = (a, b) => a + b;
const subtract: BinaryFn = (a, b) => a - b;
const multiply: BinaryFn = (a, b) => a * b;
 
// vs. typing each parameter (repetitive)
const divide = (a: number, b: number): number => a / b;

Benefits of Typing the Expression

TYPESCRIPT
// 1. Less repetition
type AsyncFn<T> = () => Promise<T>;
 
const fetchUser: AsyncFn<User> = async () => {
  // Return type is inferred as Promise<User>
  const response = await fetch("/api/user");
  return response.json();
};
 
// 2. Catches mistakes in the function body
const add: BinaryFn = (a, b) => {
  return a.toString() + b; // Error! Not a number
};
 
// 3. Better for callback-heavy code
const numbers = [1, 2, 3, 4, 5];
numbers.map((n): number => n * 2); // Explicitly typed return

Item 13: Know the Differences Between type and interface

Both type and interface can define object types:

TYPESCRIPT
// Interface
interface PersonI {
  name: string;
  age: number;
}
 
// Type alias
type PersonT = {
  name: string;
  age: number;
};

Key Differences

Unique Features of Each

TYPESCRIPT
// 1. Interfaces can be augmented (declaration merging)
interface Window {
  customProperty: string;
}
// Now Window has customProperty globally!
 
// Types cannot be augmented
type Window = { customProperty: string }; // Error if Window already exists
 
// 2. Types can use unions and primitives
type StringOrNumber = string | number;
type Name = string;
 
// Interfaces cannot
// interface Name = string; // Syntax error
 
// 3. Types can use mapped types
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
 
// 4. Interfaces can extend other interfaces
interface Animal {
name: string;
}
 
interface Dog extends Animal {
breed: string;
}
 
// Types use intersection for extension
type Cat = Animal & {
meow(): void;
};
 

When to Use Which

Note

Prefer interface for public API definitions that might need extending. Prefer type for unions, intersections, mapped types, and complex type expressions.

TYPESCRIPT
// Interface: public API
export interface UserService {
  getUser(id: string): Promise<User>;
  updateUser(id: string, data: Partial<User>): Promise<User>;
}
 
// Type: complex type expression
type UserResponse =
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };
 
// Type: mapped/utility type
type PartialUser = Partial<User>;

Item 14: Use Type Operations and Generics to Avoid Repeating Yourself

DRY (Don't Repeat Yourself) applies to types too:

TYPESCRIPT
// BAD: Repetitive type definitions
interface PersonWithBirthDate {
  name: string;
  email: string;
  dateOfBirth: Date;
}
 
interface PersonWithoutBirthDate {
  name: string;
  email: string;
}
 
// GOOD: Derive one from the other
interface Person {
  name: string;
  email: string;
}
 
interface PersonWithBirthDate extends Person {
  dateOfBirth: Date;
}
 
// Or use utility types
type PersonWithoutBirthDate = Omit<PersonWithBirthDate, "dateOfBirth">;

Useful Patterns for DRY Types

TYPESCRIPT
// 1. Indexed access types
interface APIResponse {
  user: {
    id: string;
    name: string;
    email: string;
  };
  posts: Array<{
    id: string;
    title: string;
  }>;
}
 
type User = APIResponse["user"];
type Post = APIResponse["posts"][number];
 
// 2. typeof for deriving types from values
const COLORS = {
  primary: "#007bff",
  secondary: "#6c757d",
  success: "#28a745",
} as const;
 
type Color = keyof typeof COLORS; // 'primary' | 'secondary' | 'success'
type ColorValue = (typeof COLORS)[Color]; // '#007bff' | '#6c757d' | '#28a745'
 
// 3. ReturnType for function return types
function createUser(name: string, email: string) {
  return { id: crypto.randomUUID(), name, email, createdAt: new Date() };
}
 
type NewUser = ReturnType<typeof createUser>;

Key Takeaways

  1. Use your editor to explore types - hover, go to definition, find references
  2. Think of types as sets - union is OR, intersection is AND
  3. Know type space vs value space - typeof behaves differently in each
  4. Prefer type declarations over assertions for better error checking
  5. Use lowercase primitives - string not String
  6. Excess property checking only applies to object literals
  7. Apply types to function expressions to reduce repetition
  8. Use interface for extendable types, type for unions/complex types
  9. Use type operations to avoid repeating type definitions

In the next chapter, we'll explore how TypeScript infers types and when you should provide explicit annotations.