# TypeScript essentials

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

Frontend Developer roadmap · TypeScript & React · 17 min read · https://levelcode.org/roadmaps/frontend/typescript-essentials

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.

> [!NOTE]
> An optional property `description?: string` and a property typed `description: string | undefined` are not the same. The first can be left out entirely; the second must be present, even if its value is `undefined`.

## 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:

| Utility | Result |
| --- | --- |
| `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
}
```

> [!WARNING]
> `const user = (await res.json()) as User` compiles, but nothing checks that the server actually sent a `User`. Types are erased, so data from outside your program (APIs, `localStorage`, URL params) needs runtime validation, either by hand or with a schema library such as Zod.

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`.

**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.

## Quiz

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

   A. `unknown` only accepts primitive values
   B. There is no difference; they are aliases
   C. `any` is checked at runtime, `unknown` is not
   D. You must narrow an `unknown` value before using it, while `any` lets you do anything with no checking

   **Answer: D.** Both accept any value, but `unknown` forces you to prove what the value is before you use it. `any` switches checking off for that value and anything derived from it. Neither exists at runtime.

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

   A. `if (shape.kind === 'circle') { shape.radius }`
   B. `if (shape) { shape.radius }`
   C. `(shape.radius ?? 0)` with no check
   D. `if (typeof shape === 'object') { shape.radius }`

   **Answer: A.** Comparing the discriminant `kind` narrows the union to the circle member, which has `radius`. Truthiness and typeof checks do not tell the two members apart, and `radius` does not exist on the square member at all.

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

   A. `longest('abc', 'de')`
   B. `longest(10, 20)`
   C. `longest([1, 2], [3])`
   D. `longest({ length: 2 }, { length: 5 })`

   **Answer: B.** The constraint requires a `length: number` property. Strings, arrays and the object literals all have one; numbers do not.

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

   A. That it has every property of `User`, or an error is thrown
   B. That it is at least an object
   C. Nothing: the assertion only affects compile-time checking and the server could send anything
   D. That missing properties are filled in with `undefined`

   **Answer: C.** `as` is an instruction to the compiler, not a conversion or a check. Types are erased, so external data needs runtime validation if you want a real guarantee.
