article thumbnail

TypeScript Crash Course

JavaScript With a Safety Net — Now Compiled by Go

13 min read
#programming, #typescript, #javascript, #friday1

JavaScript runs the web, but it has a famous flaw: it lets you do almost anything and tells you nothing until the code is running in front of a customer. undefined is not a function, Cannot read properties of undefined, a string quietly added to a number — every JavaScript developer has met them. TypeScript is the fix. It is JavaScript plus a type system that checks your code before it runs, and then gets out of the way by compiling down to plain JavaScript.

Microsoft released TypeScript in 2012, led by Anders Hejlsberg — the same designer behind Turbo Pascal, Delphi, and C#. Today it is the default choice for serious front-end and Node.js work, and most new React, Angular, and Node projects start in it. If you already write JavaScript (see our JavaScript deep dive or the React guide), you already know 90% of TypeScript.

Big News: TypeScript 7 and the Native Compiler

For years the TypeScript compiler was itself written in TypeScript, running on Node. That worked, but large projects spent minutes type-checking. With TypeScript 7.0, the compiler has been rewritten as a native program in Go (the effort was nicknamed Corsa). Microsoft reports full builds on large codebases running roughly 8x to 12x faster. The VS Code codebase is the headline benchmark: about 125 seconds down to about 11.

What that means for you:

Everything below applies to both 6.x and 7.x.

Hello, TypeScript

function greet(name: string): string {
  return `Hello, ${name}!`;
}

console.log(greet("World"));

The : string annotations are the only new part. Now watch what the compiler does with a mistake:

greet(42);
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

That error appears in your editor as you type, not in production at 2 a.m.

Getting Set Up

npm init -y
npm install -D typescript
npx tsc --init          # creates tsconfig.json
npx tsc                 # compile every .ts file per tsconfig
npx tsc --noEmit        # type-check only, produce no output

You can also run a .ts file directly. Recent versions of Node.js can execute TypeScript by simply stripping the type annotations, no build step needed:

node hello.ts

This works only for "erasable" syntax — things that disappear when you delete the types. A few older features (such as enum and constructor parameter properties) generate real code and are not supported that way. Turn on "erasableSyntaxOnly": true in tsconfig.json and the compiler will warn you if you use them. Bun and Deno also run TypeScript natively, and the tsx package does the same on top of Node.

A solid starter tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Always turn on "strict": true. It is the difference between a real safety net and a decorative one.

Basic Types

let name: string = "Ada";
let age: number = 36;          // all numbers are 'number' (no int/float split)
let active: boolean = true;
let scores: number[] = [90, 85, 77];
let pair: [string, number] = ["Ada", 36];     // a tuple: fixed length and types

// Usually you can leave the annotation off. TypeScript infers it:
let city = "Provo";            // inferred as string
city = 5;                      // error: number is not assignable to string

Let inference do the work. Annotate function parameters and return types at your public boundaries; let the compiler infer local variables.

Interfaces and Type Aliases

You describe the shape of an object, and the compiler enforces it everywhere:

interface User {
  id: number;
  name: string;
  email?: string;        // optional
  readonly created: Date; // cannot be reassigned
}

const u: User = { id: 1, name: "Ada", created: new Date() };
u.created = new Date();  // error: read-only property

type does the same job and can also name unions, tuples, and more:

type ID = number | string;
type Point = { x: number; y: number };

Rule of thumb: use interface for object shapes you may extend, and type for everything else. Either works for most day-to-day code.

Union Types and Narrowing

This is where TypeScript earns its keep. A value can be one of several types, and the compiler tracks which one it is as your code branches:

function describe(id: number | string): string {
  if (typeof id === "string") {
    return id.toUpperCase();   // here id is a string
  }
  return id.toFixed(2);        // here id is a number
}

Discriminated unions model "one of these cases" cleanly, and the compiler can prove you handled them all:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
  }
}

Add a third shape later and every switch that forgets it becomes a compile error. That is a refactoring superpower.

Handling null and undefined

With strict on, null and undefined are not silently allowed everywhere. TypeScript forces you to deal with them:

function firstChar(s: string | undefined): string {
  return s[0];              // error: 's' is possibly 'undefined'
}

function firstCharSafe(s: string | undefined): string {
  return s?.[0] ?? "";      // optional chaining plus a default
}

?. (optional chaining) and ?? (nullish coalescing) are standard JavaScript now, and they pair perfectly with the type checker. Avoid the non-null assertion s! — it just tells the compiler to stop checking, which defeats the purpose.

Functions

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

const double = (n: number): number => n * 2;

// A function type
type Handler = (event: string, payload?: unknown) => void;

// Rest parameters
function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

Generics

Generics let you write one function that works for many types while staying fully type-safe:

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const n = first([1, 2, 3]);        // number | undefined
const s = first(["a", "b"]);       // string | undefined

interface ApiResult<T> {
  ok: boolean;
  data: T;
}

async function getJson<T>(url: string): Promise<ApiResult<T>> {
  const res = await fetch(url);
  return { ok: res.ok, data: (await res.json()) as T };
}

You will meet generics constantly in libraries (Array<T>, Promise<T>, Map<K, V>), so it pays to be comfortable reading them.

Utility Types

TypeScript ships helpers that transform existing types, so you don't repeat yourself:

interface User { id: number; name: string; email: string }

type NewUser     = Omit<User, "id">;            // everything but id
type UserPreview = Pick<User, "id" | "name">;   // just these fields
type UserPatch   = Partial<User>;               // every field optional
type FrozenUser  = Readonly<User>;              // every field read-only
type UserMap     = Record<number, User>;        // object keyed by id

ReturnType<typeof fn>, Parameters<typeof fn>, and Awaited<Promise<T>> are also worth knowing.

Classes

class Account {
  private balance = 0;

  constructor(public readonly owner: string) {}

  deposit(amount: number): void {
    if (amount <= 0) throw new Error("Amount must be positive");
    this.balance += amount;
  }

  get total(): number {
    return this.balance;
  }
}

const a = new Account("Ada");
a.deposit(50);
console.log(a.total);   // 50
a.balance;              // error: 'balance' is private

Note that private is checked only at compile time. If you need true runtime privacy, use JavaScript's #balance syntax instead.

Async/Await

interface Repo { name: string; stargazers_count: number }

async function topRepos(user: string): Promise<Repo[]> {
  const res = await fetch(`https://api.github.com/users/${user}/repos`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const repos = (await res.json()) as Repo[];
  return repos.sort((a, b) => b.stargazers_count - a.stargazers_count);
}

The Biggest Gotcha: Types Vanish at Runtime

TypeScript types exist only while compiling. The as Repo[] above is a promise you make to the compiler, not a check. If the server returns something different, TypeScript will not notice, and your code will fail at runtime anyway.

Anything that crosses a trust boundary — an API response, a form submission, JSON.parse, a config file — should be validated at runtime. The popular approach is a schema library such as Zod, which gives you a runtime validator and the matching TypeScript type from one definition:

import { z } from "zod";

const RepoSchema = z.object({ name: z.string(), stargazers_count: z.number() });
type Repo = z.infer<typeof RepoSchema>;

const repo = RepoSchema.parse(await res.json());   // throws if the shape is wrong

unknown vs any

function parse(input: unknown): string {
  if (typeof input === "string") return input;
  throw new Error("Expected a string");
}

Working with JavaScript Libraries

Most npm packages ship their own types. For older ones, the community publishes them under @types:

npm install express
npm install -D @types/express @types/node

TypeScript and JavaScript can coexist in one project, so you can migrate a codebase file by file: rename .js to .ts, fix the errors, repeat. Set "allowJs": true in tsconfig.json while you transition.

Quick Reference: Coming From Another Language

You know... In TypeScript...
JavaScript The same, plus : type annotations and interface/type
Python type hints Similar idea, but TypeScript is checked by the compiler on every build and is far more expressive
Java / C# Structural, not nominal: two types with the same shape are compatible without declaring a relationship
Rust / Swift Union types and narrowing play the role of enums with exhaustive matching
PHP Think of declare(strict_types=1) taken much further, including for arrays and objects

Gotchas That Trip Up Newcomers

  1. Typing is structural. An object with extra properties is still assignable where a smaller shape is expected (except for fresh object literals, which get extra-property checks).
  2. as is a cast, not a conversion. "5" as number is an error, and x as unknown as number just hides a bug.
  3. enum is the odd one out. It generates runtime code; many teams prefer a union of string literals (type Mode = "dev" | "prod").
  4. Array index access can be undefined. Turn on noUncheckedIndexedAccess so arr[5] is typed T | undefined.
  5. Compile success is not correctness. The compiler cannot see runtime data. Validate at the edges.

Where TypeScript Shines (and Where It Doesn't)

Shines: large codebases, teams, APIs shared between front end and back end, long-lived projects, and refactoring. Editor autocomplete and rename-everywhere alone are worth the price of admission.

Less compelling: tiny throwaway scripts, or places where you need a build step you otherwise wouldn't have. (Even there, running .ts files directly in Node or Bun removes most of that friction.)

Getting Started Checklist

  1. Install Node.js, then npm install -D typescript in a new folder.
  2. Run npx tsc --init and set "strict": true.
  3. Write a function with typed parameters, then deliberately pass the wrong type and read the error.
  4. Model a real piece of your data with an interface and a discriminated union.
  5. Convert one existing .js file to .ts and fix what the compiler finds — you will probably find a real bug.
  6. Add a runtime validator (Zod) at one API boundary.

The best way to learn TypeScript is to let the compiler argue with you. Every error it raises is a bug you didn't ship.

What did you learn?

Tell us what you learned from this article, or what you liked about it. We read every comment.

Comments appear after a quick review. Please no links.

Know someone who'd love it too?

Most covered topics