Step 11 of 21

TypeScript essentials

Describe your data with types, interfaces and unions, narrow values safely, and write reusable generic functions.

17 min 9 sections

TypeScript is JavaScript with a type system on top. It catches whole categories of bugs before your code runs, like calling a method on undefined or misspelling a property, and it gives your editor real autocomplete. This lesson covers the core you will use every day: annotations and inference, object types, unions and narrowing, generics, and the escape hatches you should reach for rarely.

How TypeScript runs

TypeScript code is checked by the compiler, tsc, and then the types are erased. What runs in the browser is plain JavaScript; there is no type information at runtime.

ts
function greet(name: string) {
  return `Hello, ${name.toUpperCase()}`
}

greet(42)
text
$ npx tsc --noEmit
src/greet.ts:5:7 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

In a Vite project, Vite strips the types and tsc --noEmit (or tsc -b) does the checking. Settings live in tsconfig.json. The one setting that matters most is "strict": true: it turns on strictNullChecks, which makes null and undefined separate types you must handle, and noImplicitAny, which stops untyped parameters from silently becoming any. Always use it.

Annotations and inference

You annotate with : Type. But most of the time you do not need to, because TypeScript infers types from values:

ts
let count = 0            // number
const label = 'total'    // 'total' (a const can never change, so its type is the literal)
const tags = ['a', 'b']  // string[]

count = 'ten'            // error: Type 'string' is not assignable to type 'number'

The rule of thumb is to annotate function parameters (TypeScript cannot guess them) and let everything else be inferred. Annotate return types when a function is part of a public API, so a mistake inside it is reported there rather than at every call site.

ts
function average(values: number[]): number {
  if (values.length === 0) return 0
  return values.reduce((a, b) => a + b, 0) / values.length
}

The basic types are string, number, boolean, bigint, symbol, null and undefined. Arrays are string[] (or Array<string>), fixed-length tuples are [number, number], and void means a function returns nothing useful.

Object types: type aliases and interfaces

Objects are described by their shape. You can name a shape with a type alias or an interface:

ts
type Product = {
  id: string
  name: string
  priceCents: number
  description?: string        // optional: string | undefined
  readonly createdAt: Date    // cannot be reassigned after creation
}

interface User {
  id: string
  email: string
}

interface Admin extends User {
  permissions: string[]
}

TypeScript is structurally typed: any value with the right properties fits the type, whatever it was called or wherever it was created. There is no need to declare that an object "implements" User.

Both forms describe objects equally well. Only type can name unions, tuples and other non-object types, while interfaces with the same name merge their declarations (useful for extending library types, surprising otherwise). Pick one convention for object shapes and stick to it.

Unions and literal types

A union says a value is one of several types. Combined with literal types, unions model real-world states precisely:

ts
type Size = 'sm' | 'md' | 'lg'
type Id = string | number

function buttonClass(size: Size) {
  return `btn btn-${size}`
}

buttonClass('md')   // ok
buttonClass('xl')   // error: Argument of type '"xl"' is not assignable to parameter of type 'Size'

With strict null checks, "might be missing" is also a union. document.querySelector('form') returns HTMLFormElement | null, and TypeScript will not let you call .submit() on it until you have ruled out null. That brings us to narrowing.

Narrowing

On a union you can only use what every member has in common. To use something specific, you narrow: you write an ordinary JavaScript check, and TypeScript follows your control flow and refines the type inside each branch.

ts
function formatId(id: string | number) {
  if (typeof id === 'number') {
    return id.toFixed(0)     // id: number
  }
  return id.trim()           // id: string
}

function submit(form: HTMLFormElement | null) {
  if (!form) return          // truthiness check removes null
  form.requestSubmit()       // form: HTMLFormElement
}

The checks TypeScript understands include typeof, truthiness, === comparisons, instanceof, Array.isArray and the in operator (if ('permissions' in user)).

Discriminated unions

The most useful pattern in all of TypeScript is a union of object types that share a literal discriminant property:

ts
type RequestState =
  | { status: 'loading' }
  | { status: 'success'; data: Product[] }
  | { status: 'error'; message: string }

function describe(state: RequestState): string {
  switch (state.status) {
    case 'loading':
      return 'Loading…'
    case 'success':
      return `${state.data.length} products`   // data exists only here
    case 'error':
      return `Failed: ${state.message}`
    default: {
      const unreachable: never = state
      return unreachable
    }
  }
}

Checking state.status narrows the whole object, so state.data is only accessible where it really exists. Compare that with the common alternative, { loading: boolean; data?: Product[]; error?: string }, which allows nonsense such as loading: true together with an error.

The default branch is an exhaustiveness check. never is the type with no values. If every case is handled, state has been narrowed to never and the assignment compiles. Add a fourth status later and forget to handle it, and that line becomes a compile error that points you to the gap.

Custom type guards

When a check is too complex to inline, write a function whose return type is a type predicate:

ts
function isAdmin(user: User | Admin): user is Admin {
  return 'permissions' in user
}

TypeScript trusts the predicate, so make sure it is actually true.

Generics

Sometimes a function works with any type, but you want to keep track of which one. A generic is a type parameter, written in angle brackets, filled in when the function is called:

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

const n = first([1, 2, 3])        // T = number, so n: number | undefined
const s = first(['a', 'b'])       // T = string, so s: string | undefined

With any instead, n would be any and later mistakes would go unnoticed. Generics keep the link between input and output.

Constraints

Use extends to require that a type parameter has certain features, and keyof to talk about an object's property names:

ts
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b
}

longest('apple', 'fig')     // ok, strings have length
longest([1, 2], [3])        // ok, arrays have length
longest(10, 20)             // error: number has no 'length'

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key])
}

const products: Product[] = []
const names = pluck(products, 'name')   // string[]
pluck(products, 'nmae')                 // error: '"nmae"' is not assignable to 'keyof Product'

Types can be generic too, such as type ApiResult<T> = { ok: true; value: T } | { ok: false; error: string }.

Utility types

TypeScript ships generic helpers that transform existing types, so you derive types instead of copying them:

UtilityResult
Partial<Product>every property optional (good for update payloads)
Pick<Product, 'id' | 'name'>only those properties
Omit<Product, 'createdAt'>everything except those properties
Record<Size, number>an object with a number for each key 'sm', 'md', 'lg'
ReturnType<typeof average>the return type of a function (number)

Escape hatches and their cost

TypeScript gives you ways to overrule it. Each one moves responsibility from the compiler back to you.

  • any turns off checking for a value and everything derived from it. It spreads silently.
  • unknown is the safe alternative for values you cannot type up front: anything can be assigned to it, but you must narrow it before using it.
  • as assertions (value as Product) tell the compiler to trust you. They are not conversions and they do nothing at runtime.
  • The non-null assertion el! claims a value is not null or undefined. If you are wrong, you get the runtime crash TypeScript was trying to prevent.
ts
const raw: unknown = JSON.parse(text)

raw.name                      // error: 'raw' is of type 'unknown'
if (typeof raw === 'object' && raw !== null && 'name' in raw) {
  console.log(raw.name)       // ok after narrowing
}

The satisfies operator is the exception: it checks a value against a type without widening it, so you keep the precise inferred type:

ts
const sizes = { sm: 8, md: 12, lg: 16 } satisfies Record<Size, number>
sizes.md   // number, and a missing or misspelled key would be an error

Practice

Create a file practice.ts in any TypeScript project (or run npm init -y && npm install -D typescript in an empty folder and use npx tsc --noEmit --strict practice.ts).

  1. Define a generic discriminated union RemoteData<T> with four states: idle, loading, success (with data: T) and failure (with error: string).
  2. Write render<T>(state: RemoteData<T>, show: (data: T) => string): string that handles every state and includes an exhaustiveness check.
  3. Write a generic sortBy<T>(items: readonly T[], getKey: (item: T) => string | number): T[] that returns a new sorted array without mutating the input.
  4. Add a fifth state, refreshing with data: T, and confirm that the compiler points you at render.
Show solution
ts
type RemoteData<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'failure'; error: string }

function render<T>(state: RemoteData<T>, show: (data: T) => string): string {
  switch (state.status) {
    case 'idle':
      return 'Nothing loaded yet'
    case 'loading':
      return 'Loading…'
    case 'success':
      return show(state.data)
    case 'failure':
      return `Error: ${state.error}`
    default: {
      const unreachable: never = state
      return unreachable
    }
  }
}

function sortBy<T>(items: readonly T[], getKey: (item: T) => string | number): T[] {
  return [...items].sort((a, b) => {
    const ka = getKey(a)
    const kb = getKey(b)
    return ka < kb ? -1 : ka > kb ? 1 : 0
  })
}

type Book = { title: string; year: number }
const books: Book[] = [
  { title: 'Dune', year: 1965 },
  { title: 'Neuromancer', year: 1984 },
  { title: 'Foundation', year: 1951 },
]

console.log(sortBy(books, (b) => b.year).map((b) => b.title))
// ["Foundation", "Dune", "Neuromancer"]

console.log(render<Book[]>({ status: 'success', data: books }, (d) => `${d.length} books`))
// "3 books"

Taking readonly T[] promises callers that sortBy will not mutate their array; because .sort() sorts in place, the function copies it first with [...items].

When you add | { status: 'refreshing'; data: T }, the default branch now receives that case, so the line const unreachable: never = state fails with "Type '{ status: "refreshing"; data: T; }' is not assignable to type 'never'". That error is exactly the reminder you want.

Key takeaways

  • Types are checked by tsc and erased at runtime; turn on strict and run the checker in your editor and CI.
  • Annotate function parameters and public return types; let inference handle the rest.
  • Model states as discriminated unions and narrow them with ordinary checks; use never for exhaustiveness.
  • Generics keep the link between input and output types; constrain them with extends and keyof.
  • Derive types with Partial, Pick, Omit and Record instead of duplicating them.
  • Prefer unknown to any, treat as and ! as promises you must keep, and validate external data at runtime.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What is the key difference between `unknown` and `any`?

  2. 2.Given `type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }`, which code lets you read `shape.radius` without an error?

  3. 3.With `function longest<T extends { length: number }>(a: T, b: T): T`, which call is a compile error?

  4. 4.You write `const user = (await res.json()) as User`. What does TypeScript guarantee about `user` at runtime?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.