Step 11 of 21
TypeScript essentials
Describe your data with types, interfaces and unions, narrow values safely, and write reusable generic functions.
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.
function greet(name: string) {
return `Hello, ${name.toUpperCase()}`
}
greet(42)$ 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:
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.
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:
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:
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.
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:
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:
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:
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 | undefinedWith 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:
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.
anyturns off checking for a value and everything derived from it. It spreads silently.unknownis the safe alternative for values you cannot type up front: anything can be assigned to it, but you must narrow it before using it.asassertions (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 notnullorundefined. If you are wrong, you get the runtime crash TypeScript was trying to prevent.
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:
const sizes = { sm: 8, md: 12, lg: 16 } satisfies Record<Size, number>
sizes.md // number, and a missing or misspelled key would be an errorPractice
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).
- Define a generic discriminated union
RemoteData<T>with four states:idle,loading,success(withdata: T) andfailure(witherror: string). - Write
render<T>(state: RemoteData<T>, show: (data: T) => string): stringthat handles every state and includes an exhaustiveness check. - Write a generic
sortBy<T>(items: readonly T[], getKey: (item: T) => string | number): T[]that returns a new sorted array without mutating the input. - Add a fifth state,
refreshingwithdata: T, and confirm that the compiler points you atrender.
Show solution
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
tscand erased at runtime; turn onstrictand 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
neverfor exhaustiveness. - Generics keep the link between input and output types; constrain them with
extendsandkeyof. - Derive types with
Partial,Pick,OmitandRecordinstead of duplicating them. - Prefer
unknowntoany, treatasand!as promises you must keep, and validate external data at runtime.
Check yourself
Quiz
0/4 correct · 4 left
1.What is the key difference between `unknown` and `any`?
2.Given `type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }`, which code lets you read `shape.radius` without an error?
3.With `function longest<T extends { length: number }>(a: T, b: T): T`, which call is a compile error?
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.