RoadsideCoderNotes
typescriptjavascript

TypeScript Crash Course (Full Notes)

Complete written notes for the TypeScript crash course: setup and tsconfig, primitive types, functions, interfaces vs. types, unions and generics, classes, utility types, enums, and promises.

Full written notes to follow along with the video, or come back to as a reference later. TypeScript is JavaScript with a type system layered on top: it catches a whole category of bugs before your code ever runs, at the cost of an extra compile step.

Note

TypeScript code never runs directly. It compiles down to plain JavaScript, since that's the only thing browsers and Node.js actually understand.

Setup

Install the compiler globally, or per-project if you'd rather pin a version per repo:

npm install -g typescript
tsc --version

Initialize a project and a TypeScript config:

npm init -y
npx tsc --init

tsc --init generates tsconfig.json, which controls how your .ts files compile. The settings that matter most starting out:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
  • rootDir / outDir — where your TypeScript source lives, and where compiled JavaScript gets written
  • target — which JS version the output should compile down to
  • strict — turns on strict type checking. Leave this true; disabling it defeats most of the point of using TypeScript at all

Compile with tsc, then run the compiled output like any other Node file:

tsc
node dist/basics.js

Primitive types

let username: string = "roadsidecoder";
let age: number = 27;
let isActive: boolean = true;
let scores: number[] = [1, 2, 3, 4];
let names: string[] = ["Alice", "Bob"];

Assigning the wrong type is a compile error, not a runtime surprise:

let age: number = "twenty-seven";
// Error: Type 'string' is not assignable to type 'number'.

A tuple is a fixed-length array where each position has its own type:

let person: [string, number] = ["Alice", 29];

An enum restricts a value to a fixed set of named options:

enum Color {
  Red,
  Green,
  Blue,
}
 
let favorite: Color = Color.Blue;

any disables type checking for a value entirely, which quietly removes the safety TypeScript exists to provide. Prefer unknown when you genuinely don't know a value's type ahead of time; it still forces you to narrow the type before using it:

let userInput: unknown = "hello";
// userInput.length would error here — unknown has no methods until narrowed

Functions

function subscribe(message: string): void {
  console.log(message);
}

The parameter type comes after the parameter name; the return type comes after the parameter list. void means the function doesn't return a usable value.

An optional parameter uses ?, and default parameters work exactly like plain JavaScript:

function greet(name: string, greeting?: string): string {
  return greeting ? `${greeting}, ${name}` : `Hello, ${name}`;
}
 
function multiply(a: number, b: number = 2): number {
  return a * b;
}

Rest parameters get typed as an array:

function sum(...nums: number[]): number {
  return nums.reduce((total, n) => total + n, 0);
}

Arrow functions type the same way, and you can define a reusable function type separately from any specific implementation:

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

Objects, interfaces, and types

Typing an object inline gets messy fast, so interface exists to name a shape once and reuse it:

interface User {
  name: string;
  age: number;
  email?: string; // optional
  readonly id: number;
}
 
const user: User = { id: 1, name: "Alice", age: 29 };
user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.

Interfaces can include methods too:

interface Product {
  name: string;
  price: number;
  getDiscount(percent: number): number;
}

type can describe the same object shapes, plus things interfaces can't express, like a union of primitives:

type ID = string | number;
 
const userId: ID = "abc123";
const productId: ID = 42;

Interfaces vs. types, the two differences that actually matter day to day:

  • Interfaces can be extended; types can't be extended the same way (though types can use intersections to a similar effect).
  • Interfaces with the same name in the same scope merge automatically; redeclaring a type is a compile error.
interface Animal {
  name: string;
}
 
interface Dog extends Animal {
  breed: string;
}
 
const rex: Dog = { name: "Rex", breed: "Labrador" };

In practice: reach for interface when describing the shape of an object, and type for unions, intersections, or anything that isn't a plain object shape.

Union and intersection types

A union type accepts any one of several types:

type Status = "pending" | "approved" | "rejected";
 
function setStatus(status: Status): void {
  console.log(`Status set to ${status}`);
}
AB

A | B — a value matching either shape is valid

An intersection type combines multiple types into one, requiring all of their fields at once:

AB

A & B — a value must satisfy both shapes at once

interface Colorful {
  color: string;
}
 
interface Circle {
  radius: number;
}
 
type ColorfulCircle = Colorful & Circle;
 
const c: ColorfulCircle = { color: "red", radius: 10 };

Literal types

A literal type is a specific value treated as its own type, rather than the general string or number:

let direction: "north" | "south" | "east" | "west" = "north";
direction = "up"; // Error: not assignable

This is the same mechanism behind the Status union above, just written directly on a variable instead of a named type. It's especially useful for modeling API responses that vary by shape:

type SuccessResponse = { data: unknown };
type ErrorResponse = { message: string };
type ApiResponse = SuccessResponse | ErrorResponse;

Type assertions and type guards

A type assertion tells the compiler "trust me, I know the real type here" without changing anything at runtime:

const someValue: unknown = "subscribe to roadsidecoder";
const strLength: number = (someValue as string).length;

A type guard narrows a union type down to one specific branch, based on a runtime check:

string | numbertypeof value=== "string"true branchvalue: stringelse branchvalue: number

Inside each branch, TypeScript narrows the type automatically — no assertion needed.

function processValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase()); // TypeScript knows it's a string here
  } else {
    console.log(value.toFixed(2)); // and a number here
  }
}

instanceof works as a type guard for classes:

class Dog {
  bark() { console.log("Woof"); }
}
 
class Cat {
  meow() { console.log("Meow"); }
}
 
function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

Classes

TypeScript classes work like JavaScript classes, plus access modifiers and typed properties:

private
publicanywhereprotectedclass + subclassesprivateclass only
class Person {
  private name: string;
  protected age: number;
  public email: string;
 
  constructor(name: string, age: number, email: string) {
    this.name = name;
    this.age = age;
    this.email = email;
  }
 
  introduce(): string {
    return `Hi, I'm ${this.name}`;
  }
 
  get displayName(): string {
    return this.name;
  }
}
  • private — accessible only inside the class itself
  • protected — accessible inside the class and any subclass
  • public (the default) — accessible from anywhere

Constructor parameters can declare their access modifier directly, skipping the separate property declarations:

class Employee {
  constructor(
    private id: number,
    public name: string,
    protected department: string,
  ) {}
 
  getDetails(): string {
    return `${this.name} works in ${this.department}`;
  }
}

Inheritance uses extends, and super(...) forwards constructor arguments to the parent class:

class Manager extends Employee {
  constructor(
    id: number,
    name: string,
    department: string,
    private teamSize: number,
  ) {
    super(id, name, department);
  }
 
  getTeamInfo(): string {
    return `${this.name} manages a team of ${this.teamSize}`;
  }
}

A subclass gets every public and protected member of its parent, but not private ones.

Generics

Without generics, a reusable function loses type information the moment it accepts more than one possible type:

function identity(arg: any) {
  return arg;
}
 
const a = identity("hello"); // typed as `any`, not `string`

A generic parameter (<T>) lets the function stay reusable while keeping the specific type intact:

identity<T>identity("hello")T = stringreturns stringidentity(42)T = numberreturns number

Same function, same logic — T is substituted per call instead of being fixed or widened to any.

function identity<T>(arg: T): T {
  return arg;
}
 
const a = identity("hello"); // inferred as string
const b = identity<number>(42); // explicitly provided

Generics work the same way on arrays and interfaces:

function getFirstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
interface KeyValuePair<K, V> {
  key: K;
  value: V;
}
 
const pair: KeyValuePair<string, number> = { key: "age", value: 27 };

And on classes:

class DataStorage<T> {
  private data: T[] = [];
 
  addItem(item: T) {
    this.data.push(item);
  }
 
  getItems(): T[] {
    return this.data;
  }
}
 
const textStorage = new DataStorage<string>();
textStorage.addItem("hello");

Generics can be constrained to only accept types with a particular shape:

interface Lengthwise {
  length: number;
}
 
function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}
 
logLength("hello"); // fine — strings have .length
logLength([1, 2, 3]); // fine — arrays have .length
logLength(42); // Error: number has no .length

Utility types

TypeScript ships a set of built-in helpers for transforming existing types. Starting from:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
}
  • Partial<T> — makes every field optional, useful for update payloads:
    type PartialTodo = Partial<Todo>;
  • Required<T> — the opposite, makes every field mandatory:
    type RequiredTodo = Required<Todo>;
  • Readonly<T> — locks every field after initial assignment:
    type ReadonlyTodo = Readonly<Todo>;
  • Pick<T, Keys> — keeps only the listed fields:
    type TodoPreview = Pick<Todo, "title" | "completed">;
  • Omit<T, Keys> — keeps everything except the listed fields:
    type TodoWithoutDate = Omit<Todo, "createdAt">;
  • Record<Keys, Value> — builds an object type from a union of keys, all sharing one value type:
    type Page = "home" | "about" | "contact";
     
    interface PageInfo {
      title: string;
      url: string;
    }
     
    type Pages = Record<Page, PageInfo>;
    // { home: PageInfo; about: PageInfo; contact: PageInfo }
  • ReturnType<T> — extracts a function's return type without repeating it manually:
    function createUser() {
      return { id: 1, name: "Alice", email: "alice@example.com" };
    }
     
    type User = ReturnType<typeof createUser>;

Enums, revisited

Numeric enums auto-increment from the first value unless you set one explicitly:

enum Direction {
  Up = 1,
  Down,
  Left,
  Right,
}

Down is 2, Left is 3, Right is 4 — each one picks up from the previous value.

String enums require every member to have an explicit value, which makes them easier to debug ("pending" instead of 0):

enum Status {
  Pending = "pending",
  Approved = "approved",
  Rejected = "rejected",
}

const enum compiles away entirely (no runtime object is generated), which makes it more performant when you don't need to iterate over the enum's values at runtime:

const enum HttpStatus {
  OK = 200,
  NotFound = 404,
  ServerError = 500,
}

Promises and async/await

Type the resolved value inside Promise<...>:

function fetchUser(id: number): Promise<{ id: number; name: string }> {
  return fetch(`/api/users/${id}`).then((res) => res.json());
}

async functions follow the same rule. Promise<void> if nothing meaningful is returned:

async function logUser(id: number): Promise<void> {
  const user = await fetchUser(id);
  console.log(user);
}

Generic async functions type the resolved value the same way any other generic does:

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

Recap

  • TypeScript compiles to JavaScript; it never runs directly, and tsconfig.json controls that compile step.
  • strict: true in tsconfig.json is what makes TypeScript actually useful — avoid any, and reach for unknown when a type is genuinely not known ahead of time.
  • Interfaces describe object shapes and can be extended or merged; type covers everything else, including unions and intersections.
  • Type guards (typeof, instanceof) narrow a union down to one branch at a time, based on a runtime check.
  • Generics keep a function, class, or interface reusable without losing the specific type information for each call.
  • Utility types (Partial, Pick, Omit, Record, ReturnType, and others) transform existing types instead of redefining them by hand.

Want to see TypeScript in a real project?

See how these same TypeScript fundamentals show up in a full-stack Next.js project, end to end.

Watch the full project video