TypeScript's Type System
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
// 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:
// 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 valuesSet Operations in Types
// 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
// 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
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:
// '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
// 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.
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) => PointItem 9: Prefer Type Declarations to Type Assertions
There are two ways to tell TypeScript what type a value has:
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
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!
// 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 errorWhen Assertions Are Appropriate
// 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 nullWarning
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:
// 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?
// 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 thisNote
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":
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:
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
// 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:
// 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
// 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 returnItem 13: Know the Differences Between type and interface
Both type and interface can define object types:
// Interface
interface PersonI {
name: string;
age: number;
}
// Type alias
type PersonT = {
name: string;
age: number;
};Key Differences
Unique Features of Each
// 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.
// 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:
// 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
// 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
- Use your editor to explore types - hover, go to definition, find references
- Think of types as sets - union is OR, intersection is AND
- Know type space vs value space -
typeofbehaves differently in each - Prefer type declarations over assertions for better error checking
- Use lowercase primitives -
stringnotString - Excess property checking only applies to object literals
- Apply types to function expressions to reduce repetition
- Use
interfacefor extendable types,typefor unions/complex types - 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.