Chapter 2: The Type System You Already Know

JavaScript has types. You’ve been working with them the entire time. typeof 42 === 'number'. Array.isArray([]). null, undefined, objects, functions—these are all types.

TypeScript didn’t invent types. It made them explicit and enforced at compile time instead of implicit and checked at runtime.

The Primitive Types

You already know these. TypeScript just names them.

JavaScript vs TypeScript

// JavaScript - types exist, but only at runtime
const name = 'Alice';
const age = 30;
const isActive = true;
const nothing = null;
const notDefined = undefined;

typeof name;      // 'string'
typeof age;       // 'number'
typeof isActive;  // 'boolean'
typeof nothing;   // 'object' (JavaScript's famous quirk)
typeof notDefined; // 'undefined'
// TypeScript - types are explicit and checked at compile time
const name: string = 'Alice';
const age: number = 30;
const isActive: boolean = true;
const nothing: null = null;
const notDefined: undefined = undefined;

But here’s the thing: you rarely need to write those annotations. TypeScript infers them.

const name = 'Alice';        // TypeScript knows this is string
const age = 30;              // TypeScript knows this is number
const isActive = true;       // TypeScript knows this is boolean

// Hover over these in VS Code and you'll see the inferred types

Type inference is TypeScript’s superpower. The compiler is smart enough to figure out most types from context.

When Inference Fails (and you need annotations)

let x;           // Type: any (TypeScript doesn't know what this will be)
x = 42;          // Still any
x = 'hello';     // Still any, no error

let y: number;   // Type: number (even though uninitialized)
y = 42;          // OK
y = 'hello';     // Error: Type 'string' is not assignable to type 'number'

Rule of thumb: Let TypeScript infer when possible. Annotate when you must.

Arrays and Objects

Arrays

JavaScript arrays can hold anything. TypeScript arrays have opinions.

// Inferred as number[]
const numbers = [1, 2, 3];
numbers.push(4);      // OK
numbers.push('five'); // Error

// Inferred as string[]
const names = ['Alice', 'Bob'];
names.push('Charlie'); // OK
names.push(42);        // Error

// Explicit annotation
const scores: number[] = [];
scores.push(100); // OK

// Alternative syntax (same meaning)
const grades: Array<number> = [];

Mixed arrays? TypeScript handles them with union types (we’ll cover unions properly in Chapter 7, but here’s a preview):

// Array can hold numbers OR strings
const mixed: (number | string)[] = [1, 'two', 3, 'four'];
mixed.push(5);       // OK
mixed.push('six');   // OK
mixed.push(true);    // Error: boolean not allowed

Objects

JavaScript objects are bags of properties. TypeScript wants to know what’s in the bag.

// JavaScript - anything goes
const user = {
  name: 'Alice',
  age: 30
};

user.email = 'alice@example.com'; // Surprise! A new property appears
// TypeScript - infers shape from initialization
const user = {
  name: 'Alice',
  age: 30
};

// TypeScript knows user has name (string) and age (number)
user.name.toUpperCase(); // OK
user.age.toFixed(2);     // OK

user.email = 'alice@example.com';
// Error: Property 'email' does not exist on type '{ name: string; age: number; }'

Wait, you can’t add properties? Not quite. You can’t add properties the compiler doesn’t know about.

// Explicit type allows exactly these properties
const user: { name: string; age: number; email?: string } = {
  name: 'Alice',
  age: 30
};

user.email = 'alice@example.com'; // OK now (email is optional, note the ?)

The ? means optional. We’ll dig deeper into object types in Chapter 5.

Functions

Functions in JavaScript can be called with anything. TypeScript wants to know what goes in and what comes out.

// JavaScript
function add(a, b) {
  return a + b;
}

add(2, 3);        // 5
add('hello', ' world'); // 'hello world'
add(2, 'three');  // '2three' (type coercion surprise!)
// TypeScript
function add(a: number, b: number): number {
  return a + b;
}

add(2, 3);        // 5
add('hello', ' world'); // Error: Argument of type 'string' is not assignable to parameter of type 'number'
add(2, 'three');  // Error

The return type (: number after the parameters) is often inferred:

function add(a: number, b: number) {  // Return type inferred as number
  return a + b;
}

Arrow functions work the same way:

const add = (a: number, b: number): number => a + b;

// Or with inferred return type
const add = (a: number, b: number) => a + b;

Functions get a whole chapter (Chapter 4) because they’re where TypeScript really shines.

The any Type (and why you should avoid it)

any is TypeScript’s escape hatch. It means “I don’t know (or care) what type this is.”

let anything: any = 42;
anything = 'hello';
anything = { name: 'Alice' };
anything.whatever.you.want.nothing.will.error; // No complaints from TypeScript

any disables type checking. It’s useful for:

But overusing any defeats the purpose of TypeScript. If everything is any, you’re just writing JavaScript with extra steps.

Better alternatives to any:

// unknown - safer than any, requires type checking before use
let value: unknown = getSomeValue();

if (typeof value === 'string') {
  value.toUpperCase(); // OK, TypeScript knows it's a string here
}

// never - for things that never happen (exhaustiveness checking)
function throwError(message: string): never {
  throw new Error(message);
  // This function never returns normally
}

The void Type

Functions that don’t return anything have type void:

function logMessage(message: string): void {
  console.log(message);
  // No return statement
}

// Inferred as void
function logError(error: Error) {
  console.error(error);
}

You rarely need to write : void explicitly. TypeScript infers it.

Literal Types

Here’s where TypeScript gets interesting. Types don’t have to be broad categories like “string” or “number.” They can be specific values.

// This is not a string, it's specifically 'red'
let color: 'red' = 'red';
color = 'blue'; // Error: Type '"blue"' is not assignable to type '"red"'

// Useful for specific sets of values
type Direction = 'north' | 'south' | 'east' | 'west';

function move(direction: Direction) {
  console.log(`Moving ${direction}`);
}

move('north'); // OK
move('up');    // Error: Argument of type '"up"' is not assignable to parameter of type 'Direction'

Literal types + unions = powerful constraints:

type Status = 'pending' | 'approved' | 'rejected';
type Port = 80 | 443 | 8080;

let status: Status = 'pending';
status = 'approved'; // OK
status = 'canceled'; // Error

let port: Port = 443;
port = 80;   // OK
port = 3000; // Error

This is way more useful than it looks. We’ll see why in Chapter 5.

Union Types (a preview)

Sometimes a value can be one of several types:

// Can be string OR number
let id: string | number;

id = 'abc-123'; // OK
id = 12345;     // OK
id = true;      // Error

// Common pattern: handling values that might not exist
function getUser(id: string | number): User | null {
  const user = database.find(id);
  return user || null;
}

const user = getUser(123);
// user is User | null, must check before using
if (user) {
  console.log(user.name); // OK, TypeScript knows user is not null here
}

Unions are fundamental to TypeScript. We’ll cover them properly in Chapter 7.

Type Aliases and Interfaces (a preview)

You can name types for reuse:

// Type alias
type UserID = string | number;
type Point = { x: number; y: number };

let id: UserID = 123;
let point: Point = { x: 10, y: 20 };

// Interface (similar, but different - covered in Chapter 5)
interface User {
  id: UserID;
  name: string;
  email: string;
}

const user: User = {
  id: 'abc-123',
  name: 'Alice',
  email: 'alice@example.com'
};

When do you use type vs interface? Chapter 5 has opinions.

Type Assertions (casting, sort of)

Sometimes you know more than TypeScript does:

// TypeScript infers this as Element | null
const input = document.getElementById('username');

// You know it's an HTMLInputElement
const input = document.getElementById('username') as HTMLInputElement;
input.value = 'Alice'; // OK, value property exists on HTMLInputElement

// Alternative syntax (same thing, avoid in React/JSX because of syntax clash)
const input = <HTMLInputElement>document.getElementById('username');

Type assertions don’t change runtime behavior. They’re compile-time instructions: “Trust me, I know this is actually this type.”

Danger: Type assertions can lie. If you’re wrong, you’ll get runtime errors.

const value = 'hello' as any as number;
value.toFixed(2); // Compiles fine, crashes at runtime

Use assertions sparingly. Usually, type guards (checking types at runtime) are safer:

const input = document.getElementById('username');

if (input instanceof HTMLInputElement) {
  input.value = 'Alice'; // TypeScript knows it's HTMLInputElement here
}

Null and Undefined

JavaScript has two “nothing” values. TypeScript preserves this distinction.

let name: string = 'Alice';
name = null;      // Error (by default in strict mode)
name = undefined; // Error (by default in strict mode)

// Allow null explicitly
let name: string | null = 'Alice';
name = null; // OK now

// Allow undefined explicitly
let name: string | undefined = 'Alice';
name = undefined; // OK now

// Allow both
let name: string | null | undefined = 'Alice';

Strict mode (which you should use) requires explicit null/undefined handling. This catches the “cannot read property of undefined” errors that plague JavaScript.

function greet(name: string | null) {
  console.log(`Hello, ${name.toUpperCase()}`);
  // Error: Object is possibly 'null'

  // Must check first
  if (name) {
    console.log(`Hello, ${name.toUpperCase()}`); // OK
  }
}

The Difference Between Runtime and Compile Time

Critical concept: TypeScript types exist only at compile time. They disappear in the generated JavaScript.

// TypeScript
function add(a: number, b: number): number {
  return a + b;
}

Compiles to:

// JavaScript (types removed)
function add(a, b) {
  return a + b;
}

This means:

interface User {
  name: string;
  age: number;
}

// This is NOT a runtime check
const user: User = JSON.parse(apiResponse);
// If apiResponse is '{"name": "Alice"}', user.age will be undefined at runtime
// TypeScript can't help here because JSON.parse returns 'any'

// Runtime validation is still your job
function isUser(obj: any): obj is User {
  return typeof obj.name === 'string' && typeof obj.age === 'number';
}

const data = JSON.parse(apiResponse);
if (isUser(data)) {
  // Now TypeScript knows data is User
  console.log(data.name, data.age);
}

Libraries like Zod, io-ts, and AJV exist specifically to bridge this gap (runtime validation that generates TypeScript types).

Type Inference Is Your Friend

The biggest mistake beginners make: over-annotating.

Bad:

const name: string = 'Alice';
const age: number = 30;
const isActive: boolean = true;

function add(a: number, b: number): number {
  const result: number = a + b;
  return result;
}

Good:

const name = 'Alice';        // Inferred as string
const age = 30;              // Inferred as number
const isActive = true;       // Inferred as boolean

function add(a: number, b: number) {  // Return type inferred
  const result = a + b;      // Inferred as number
  return result;
}

When to annotate:

Structural Typing (Duck Typing)

TypeScript doesn’t care about names. It cares about shapes.

interface Point {
  x: number;
  y: number;
}

function logPoint(p: Point) {
  console.log(`${p.x}, ${p.y}`);
}

// No explicit Point declaration needed
const point = { x: 10, y: 20 };
logPoint(point); // OK, it has x and y

// Even this works
logPoint({ x: 0, y: 0, z: 0 }); // OK, extra properties don't matter

// But not this
logPoint({ x: 10 }); // Error: Property 'y' is missing

If it walks like a duck and quacks like a duck, TypeScript treats it as a duck.

This is structural typing. Contrast with nominal typing (Java, C#) where you must explicitly declare “this is a Duck.”

Structural typing makes TypeScript flexible but can surprise you:

interface Config {
  timeout: number;
}

function setup(config: Config) {
  // ...
}

// Typo in property name - still compiles!
setup({ timeOut: 5000 });
// Error: Object literal may only specify known properties, and 'timeOut' does not exist in type 'Config'

Wait, that did error. TypeScript has a special rule: excess property checking for object literals. But it doesn’t apply to variables:

const config = { timeOut: 5000 }; // No error here (just an object with timeOut)
setup(config); // Error: Type '{ timeOut: number; }' has no properties in common with type 'Config'

Actually, this does error because the object has no properties that match Config. TypeScript’s structural typing requires at least some overlap. The difference is that the error message is less helpful than with object literals.

This is a gotcha we’ll revisit in Chapter 5.

What You’ve Learned

You already knew JavaScript types. Now you know how TypeScript represents them:

TypeScript’s type system is gradual. You can add types incrementally. You can opt out with any. You can start strict or start loose and tighten over time.

The goal isn’t to type everything. It’s to type enough that the compiler catches meaningful errors while staying out of your way.

Next Up

We’ve covered the types. Now let’s talk about the tools that check them.

Next: Chapter 3: Your TypeScript Toolchain