Programming Methodology•TypeScript•Lesson 23 of 26

From JavaScript to TypeScript

The Central Insight

Every function you write has a contract: assumptions about inputs, guarantees about outputs. In JavaScript, these contracts exist only in your head and maybe in comments. TypeScript makes contracts explicit and machine-checked.

This is not about catching typos. It is about encoding intent so that:

  1. The compiler verifies your logic
  2. Other programmers (including future you) understand your intent
  3. Refactoring becomes safe because the compiler tells you what broke

Consider a sorting function you wrote:

function bubbleSort(arr) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = 0; j < arr.length - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;
}

What is the contract here? You meant: "give me an array of numbers, I'll sort it and return it." But JavaScript sees: "give me anything, I'll do... something."

bubbleSort("hello");           // Iterates over characters, returns string
bubbleSort([1, "two", 3]);     // Compares mixed types, unpredictable
bubbleSort({ length: 3 });     // Has .length, so it "works"

The function assumes arr contains comparable numbers. This assumption is an invariant - a condition that must be true for the code to work correctly. JavaScript cannot enforce invariants. TypeScript can.

Static vs Dynamic Typing: The Fundamental Tradeoff

Every programming language makes a choice about when to check types:

ApproachWhen Types Are CheckedTradeoff
Dynamic (JavaScript, Python)Runtime - while executingFlexible but errors appear late
Static (TypeScript, Java)Compile time - before runningRigid but errors appear early

Dynamic typing is not "no types" - JavaScript has types (string, number, object). The difference is when violations are caught:

// JavaScript: runs this line, then crashes
const name = "Alice";
console.log(name.toFixed(2));  // Runtime error: toFixed is not a function
// TypeScript: refuses to compile
const name: string = "Alice";
console.log(name.toFixed(2));  // Compile error: toFixed doesn't exist on string

Types as Documentation

The deepest reason to use types: they are documentation that cannot lie.

Comments can become stale. Documentation can drift from reality. But type annotations are checked by the compiler - if they are wrong, the code does not compile.

// This comment might be wrong or outdated:
// Returns the user's full name as a string

// This type signature is always correct:
function getFullName(user: User): string { ... }

When you read typed code, you know:

  • What inputs are expected
  • What output is guaranteed
  • What invariants the author assumed

This matters more as codebases grow. In a 10-line script, you can hold everything in your head. In a 100,000-line codebase, types are how you understand code you did not write.

Your First Type Annotations

In JavaScript, we write:

function greet(name) {
  return "Hello, " + name.toUpperCase();
}

greet(123);  // Runtime error! 123.toUpperCase is not a function

In TypeScript, we add type annotations:

function greet(name: string): string {
  return "Hello, " + name.toUpperCase();
}

greet(123);  // Compile error! Argument of type 'number' is not assignable to parameter of type 'string'

The syntax : string after name declares: "this parameter must be a string." The : string after the parentheses declares: "this function returns a string." These are the function's contract, and TypeScript enforces them.

Structural Typing: A Critical Difference

TypeScript uses structural typing (also called "duck typing"). If it has the right shape, it is the right type:

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

function distance(p: Point): number {
  return Math.sqrt(p.x ** 2 + p.y ** 2);
}

// This object was never declared as Point, but it has x and y
const myLocation = { x: 3, y: 4, name: "Home" };
distance(myLocation);  // OK! Has the required shape

This is fundamentally different from nominal typing (Java, C#) where types must explicitly inherit or implement:

// Java - nominal typing
class MyLocation { int x; int y; String name; }
Point p = new MyLocation();  // Error! MyLocation is not a Point

This has profound implications:

  • You can pass plain objects to typed functions
  • Third-party objects work if they have the right shape
  • You do not need complex inheritance hierarchies

Type Narrowing: TypeScript Tracks What You Know

TypeScript follows your conditional logic and narrows types accordingly:

function process(value: string | number | null) {
  // Here, value could be string, number, or null

  if (value === null) {
    return "No value";
    // After this return, null is eliminated
  }

  // Here, value is string | number

  if (typeof value === "string") {
    return value.toUpperCase();  // TypeScript knows: string
  }

  // Here, value must be number (only option left)
  return value * 2;  // TypeScript knows: number
}

This is called control flow analysis. The compiler understands that after checking typeof value === "string", within that branch, value must be a string. This is not magic - it is logical deduction.

The same works for discriminated unions:

type Result =
  | { status: "success"; data: string }
  | { status: "error"; message: string };

function handle(result: Result) {
  if (result.status === "success") {
    console.log(result.data);    // OK - TypeScript knows this branch has data
  } else {
    console.log(result.message); // OK - TypeScript knows this branch has message
  }
}

The Basic Types

// Primitives
const name: string = "Alice";
const age: number = 30;
const isActive: boolean = true;

// Arrays - two equivalent syntaxes
const numbers: number[] = [1, 2, 3, 4, 5];
const names: Array<string> = ["Alice", "Bob", "Carol"];

// Objects with interfaces
interface User {
  name: string;
  email: string;
  age: number;
  isAdmin?: boolean;  // ? means optional
}

const user: User = {
  name: "Alice",
  email: "alice@example.com",
  age: 30
  // isAdmin is optional, so we can omit it
};

Type Inference: Let TypeScript Work

You do not need to annotate everything. TypeScript infers types from values:

const message = "Hello";     // TypeScript infers: string
const count = 42;            // TypeScript infers: number
const active = true;         // TypeScript infers: boolean

// Return types are inferred from what you return
function multiply(a: number, b: number) {
  return a * b;  // TypeScript infers return type: number
}

When to annotate explicitly:

  • Function parameters (TypeScript cannot infer these)
  • When inference picks a type that is too wide or too narrow
  • Public API boundaries where clarity matters
// Must annotate parameters - TypeScript has no way to infer them
function greet(name: string): string {
  return `Hello, ${name}!`;
}

// Inference picks 'string' but you want a literal type
const status = "pending";  // type: string
const status: "pending" = "pending";  // type: "pending" (literal)

Union Types: Either/Or

A union type allows multiple possibilities:

type ID = string | number;

function findUser(id: ID) {
  // id could be string or number
}

findUser(123);      // OK
findUser("abc");    // OK
findUser(true);     // Error! boolean is not string | number

Unions are especially useful for nullable values:

function findUser(id: string): User | null {
  // Return the user if found, null if not
}

const user = findUser("123");
// user is User | null - you must handle the null case
if (user !== null) {
  console.log(user.name);  // OK - TypeScript knows user is User here
}

Literal Types: Specific Values

Types can be specific values, not just categories:

type Direction = "north" | "south" | "east" | "west";

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

move("north");  // OK
move("up");     // Error! "up" is not assignable to Direction

This is powerful for modeling state machines, status codes, and configurations.

Use as const to infer literal types from values:

// Without as const - type is string[]
const directions = ["north", "south"];

// With as const - type is readonly ["north", "south"]
const DIRECTIONS = ["north", "south", "east", "west"] as const;

// Derive the union type from the array
type Direction = typeof DIRECTIONS[number];  // "north" | "south" | "east" | "west"

never: The Impossible Type

The never type represents values that can never occur. It enables exhaustiveness checking:

type Status = "pending" | "approved" | "rejected";

function handleStatus(status: Status): string {
  switch (status) {
    case "pending":
      return "Waiting...";
    case "approved":
      return "Approved!";
    case "rejected":
      return "Rejected.";
    default:
      // If we handled all cases, this line is unreachable
      // TypeScript infers: status is never
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

Now if someone adds a new status:

type Status = "pending" | "approved" | "rejected" | "cancelled";  // Added!

The code fails to compile because "cancelled" is not handled:

Type 'string' is not assignable to type 'never'.

any vs unknown: Escaping the Type System

Sometimes you need to accept any type. TypeScript offers two options:

// any - disables type checking entirely
function dangerous(value: any) {
  value.foo.bar.baz();  // No error! TypeScript trusts you blindly
}

// unknown - type-safe "any"
function safe(value: unknown) {
  value.foo;  // Error! Cannot access properties on unknown

  // Must narrow first
  if (typeof value === "object" && value !== null && "foo" in value) {
    console.log(value.foo);  // OK after checking
  }
}

Interfaces vs Type Aliases

Both define object shapes. The difference is subtle:

// Interface - use for object shapes, especially public APIs
interface User {
  name: string;
  email: string;
}

// Interfaces can be extended
interface Admin extends User {
  permissions: string[];
}

// Interfaces can be "declaration merged" (augmented across files)
interface User {
  age: number;  // Now User has name, email, AND age
}

// Type alias - use for unions, intersections, mapped types
type ID = string | number;

type Point = {
  x: number;
  y: number;
};

// Types cannot be merged or extended with extends
// But they support unions and intersections
type Result = { success: true; data: string } | { success: false; error: string };

Rule of thumb: Use interface for object shapes, type for everything else.

Class Access Modifiers

TypeScript adds visibility modifiers to class members:

class BankAccount {
  public owner: string;          // Accessible anywhere
  private balance: number;       // Only accessible within this class
  protected accountId: string;   // Accessible in this class and subclasses
  readonly createdAt: Date;      // Can only be set in constructor

  constructor(owner: string, initialBalance: number) {
    this.owner = owner;
    this.balance = initialBalance;
    this.accountId = crypto.randomUUID();
    this.createdAt = new Date();
  }

  public getBalance(): number {
    return this.balance;
  }

  private logTransaction(amount: number): void {
    console.log(`Transaction: ${amount}`);
  }
}

const account = new BankAccount("Alice", 100);
account.owner;        // OK - public
account.balance;      // Error! private
account.getBalance(); // OK - public method
account.createdAt = new Date();  // Error! readonly

Parameter Properties: Shorthand Syntax

Declare and assign in one step:

class User {
  constructor(
    public name: string,
    private email: string,
    readonly id: number
  ) {}
  // TypeScript creates and assigns this.name, this.email, this.id automatically
}

// Equivalent to this verbose version:
class User {
  public name: string;
  private email: string;
  readonly id: number;

  constructor(name: string, email: string, id: number) {
    this.name = name;
    this.email = email;
    this.id = id;
  }
}

Function Types and Callbacks

Functions are first-class values. You can type them:

// Inline function type
function fetchData(callback: (result: string) => void): void {
  callback("Data loaded");
}

// Named function type
type Callback = (result: string) => void;
function fetchData(callback: Callback): void {
  callback("Data loaded");
}

// Function type for methods with multiple signatures
type Comparator<T> = (a: T, b: T) => number;

function sort<T>(arr: T[], compare: Comparator<T>): T[] {
  return [...arr].sort(compare);
}

sort([3, 1, 2], (a, b) => a - b);  // [1, 2, 3]

Putting It Together: A Real Example

Here is how these concepts work together in realistic code:

// 1. Define the domain with types
interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

type Filter = "all" | "active" | "completed";

// 2. Functions with clear contracts
function filterTodos(todos: Todo[], filter: Filter): Todo[] {
  switch (filter) {
    case "all":
      return todos;
    case "active":
      return todos.filter(t => !t.completed);
    case "completed":
      return todos.filter(t => t.completed);
    default:
      // Exhaustiveness check - compiler catches missing cases
      const _never: never = filter;
      return _never;
  }
}

// 3. API function with union return type
type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

async function fetchTodos(): Promise<ApiResult<Todo[]>> {
  try {
    const response = await fetch("/api/todos");
    const data = await response.json();
    return { success: true, data };
  } catch (e) {
    return { success: false, error: String(e) };
  }
}

// 4. Using the API with type narrowing
async function loadTodos() {
  const result = await fetchTodos();

  if (result.success) {
    // TypeScript knows: result.data exists and is Todo[]
    console.log(`Loaded ${result.data.length} todos`);
  } else {
    // TypeScript knows: result.error exists and is string
    console.error(`Failed: ${result.error}`);
  }
}

Notice how the types guide the implementation:

  • Filter ensures we handle all filter cases
  • ApiResult forces callers to handle both success and failure
  • Type narrowing makes accessing the right properties safe

Check Your Understanding

What is the primary benefit of static typing?

Not quite. The correct answer is highlighted.

What does TypeScript's structural typing mean?

Not quite. The correct answer is highlighted.
The type represents values that can never occur and enables exhaustiveness checking.
Not quite.Expected: never

What's the difference between 'any' and 'unknown'?

Not quite. The correct answer is highlighted.

What does 'string | number' mean in TypeScript?

Not quite. The correct answer is highlighted.

Try It Yourself

Practice converting JavaScript to TypeScript:

Summary

You learned the foundational concepts that will shape how you think about code for years:

The Big Ideas:

  • Types are contracts - machine-checked documentation of your intent
  • Static typing catches errors at compile time, not runtime
  • Structural typing cares about shape, not inheritance
  • Type narrowing lets TypeScript track what you know through control flow

The Core Types:

  • Primitives: string, number, boolean
  • Collections: T[] or Array<T>
  • Objects: interface for shapes
  • Unions: A | B for either/or
  • Literals: "north" | "south" for specific values
  • never: for impossible cases and exhaustiveness
  • unknown: for safe "any type" handling
  • any: escape hatch (avoid it)

Practical Patterns:

  • Use as const to preserve literal types
  • Use discriminated unions for type-safe state
  • Use never in default cases to catch missing handlers
  • Let TypeScript infer when it can; annotate when clarity matters

Class Modifiers:

  • public, private, protected, readonly
  • Parameter properties for concise constructors

Next, we explore generics - writing code that works with any type while remaining type-safe. This is where TypeScript's power truly shines.