TypeScript is at its best when it prevents the bugs you actually ship: wrong shapes, missing fields, impossible states, and “works on my machine” null crashes. This guide focuses on the TypeScript types you’ll actually use day-to-day—no type-gymnastics, no flex—just the patterns that make codebases calmer, safer, and easier to refactor.
Quickstart
Want immediate value? Do these in order. Each step is small, but together they upgrade your entire codebase: better autocomplete, fewer runtime surprises, and refactors that don’t feel like defusing a bomb.
1) Turn on the safety switches (strict mode)
Most “TypeScript is annoying” moments come from incomplete settings or implicit any.
Strict mode is the baseline that makes the rest of this post pay off.
- Enable
strictand friends - Stop implicit
anyat the edges - Prefer
unknownfor external input
2) Model state with unions, not booleans
“Loading + error + data” is the classic bug farm. Replace loose flags with discriminated unions and let the compiler enforce correct handling.
- Use
typeunions for states - Switch on a
kinddiscriminator - Make impossible states unrepresentable
3) Use utility types to avoid copy/paste
Most app code is “same shape, slightly different.” Utility types express those differences without duplicating types.
Pick/Omitfor view models and DTOsRecordfor maps and lookup tablesPartialfor patch updates (carefully)
4) “Let inference work” (then add types where it matters)
The most maintainable TypeScript is often less annotated. Put types at boundaries: APIs, inputs, outputs, and shared models.
- Annotate function parameters at boundaries
- Annotate return types for public APIs
- Keep internal variables inferred
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true
}
}
If you changed a function signature and the compiler shows 0 errors… you probably didn’t type the right boundary. Move the type “outward” to the public surface.
Overview
TypeScript isn’t about adding types everywhere. It’s about adding useful constraints so your editor and compiler can do real work: prevent invalid states, guide refactors, and make APIs self-documenting.
What you’ll learn (the practical 20%)
- How to pick between type aliases and interfaces
- How to use unions, literals, and discriminated unions for state
- How to reach for utility types (Pick/Omit/Record/ReturnType…) without overcomplicating
- How to handle unknown data safely (without sprinkling
as any) - Common pitfalls that make TypeScript feel “fussy” (and how to avoid them)
This post intentionally skips the “flex” side: overly clever conditional types, deep type-level programming, and patterns that make teammates squint. Those can be powerful, but most teams get more value from clear models and good boundaries.
Make wrong code hard to write and right code easy to autocomplete. That’s the sweet spot for TypeScript in real products.
Core concepts
Think of TypeScript as a design tool for your program’s shapes and states. These core concepts are the ones you’ll reuse across React apps, Node APIs, shared libraries, and even quick scripts.
1) Types belong at boundaries
Boundaries are where uncertainty enters: network responses, user input, database reads, environment variables, and third-party libraries. Once data is validated and shaped, let inference carry you.
High-value boundary types
- API request/response DTOs
- Shared domain models (User, Invoice, Task…)
- Public function signatures (exported utilities)
- Component props and event payloads
Low-value over-annotation
- Every local variable
- Every callback parameter (when it’s inferable)
- Types that repeat what the code already says
- “Make compiler happy” casts with
as any
2) Unions & literals: express “either/or” clearly
Unions are the workhorse of practical TypeScript. Combine them with literal types (string/number literals) to encode valid values and eliminate entire classes of runtime checks.
| Pattern | Example | Why you’ll actually use it |
|---|---|---|
| Literal union | "draft" | "published" |
Prevents typos and “unknown status” bugs |
| Discriminated union | { kind: "ok" } | { kind: "err" } |
Forces correct handling of each state |
| Union of shapes | User | Admin |
Model roles/features without fragile booleans |
3) unknown vs any: the “trust boundary” rule
If data comes from outside your code (API, JSON, user input), start with unknown. It forces you to narrow and validate.
any turns off the compiler exactly where you need it most.
“It’s fine, I’ll just cast it.” A few as any in the wrong places can silently undo the safety of your entire app.
Prefer narrowing functions and typed parsers at the edges.
4) Interface vs type alias (a simple rule)
Both are great. Pick one convention and keep it consistent. If you need a rule that works well in real teams:
Use interface for “object-y” public shapes
- Component props
- Library public APIs
- Shapes you expect to extend/merge
Use type for unions and composition
- State machines (
kindunions) - Utility compositions (
Omit, intersections) - Function types and callbacks
5) Utility types are your “type power tools”
Utility types keep your code DRY and aligned. Instead of defining multiple similar shapes, define one canonical model and derive “views” for specific use cases (create payload, update payload, list view, etc.).
Step-by-step
Let’s build a small but realistic “types first” setup you can copy into a codebase. The goal: fewer bugs, better autocomplete, and predictable refactors—without doing anything fancy.
Step 1 — Make the compiler work for you
Turn on strictness early. If you’re migrating a JS project, enable strict mode gradually (folder by folder), but keep a clear end goal: a codebase where new code is strictly typed and old code is being paid down intentionally.
Migration-friendly approach
- Enable
strictin new packages/modules first - Use
skipLibCheckonly as a temporary relief valve - Fix the top recurring issues (nulls, implicit any) before edge cases
- Add a small lint rule set that bans
anyat boundaries
Step 2 — Define one canonical domain model
Pick the “center” type once (the thing you store and reason about) and derive variations from it. This is where
Pick and Omit shine: you stop duplicating shapes and keep everything consistent as requirements change.
A useful mental model
Domain model = truth. DTOs/view models = projections. Don’t copy/paste models into 7 slightly different “almost the same” types.
Step 3 — Encode UI/API states as discriminated unions
This is one of the highest ROI TypeScript patterns. Instead of juggling isLoading, error, and data,
represent each state as its own shape. The compiler will make sure you handle them all.
type ApiError = {
message: string;
code?: "UNAUTHORIZED" | "NOT_FOUND" | "RATE_LIMIT";
};
type LoadState<T> =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: T }
| { kind: "error"; error: ApiError };
function assertNever(x: never): never {
throw new Error("Unhandled case: " + JSON.stringify(x));
}
function renderUser(state: LoadState<{ id: string; name: string }>) {
switch (state.kind) {
case "idle":
return "Click to load";
case "loading":
return "Loading…";
case "success":
return `Hello, ${state.data.name}`;
case "error":
return `Oops: ${state.error.message}`;
default:
return assertNever(state);
}
}
Each state has exactly the fields it needs. In "loading", there is no data to accidentally read.
In "success", there is no error to forget to clear. The type system becomes your state machine.
Step 4 — Derive “payload” types with utility types
Real apps rarely send/receive the exact same shape you store. Use utility types to define those differences explicitly and keep them in sync with the canonical type.
| Need | Reach for | Example use |
|---|---|---|
| Only some fields | Pick |
List views, UI cards, summaries |
| Everything except a few | Omit |
Create payloads (omit id/createdAt) |
| Map keys to values | Record |
Lookup tables, config maps |
| Partial updates | Partial |
Patch endpoints (use carefully) |
| Extract function info | Parameters/ReturnType |
Typed wrappers around functions |
Step 5 — Use satisfies and as const to keep config safe
Config objects are where bugs hide: typos in keys, missing cases, or values that drift from what the code expects. Two modern patterns help a lot:
as constpreserves literal types (so you don’t accidentally widen tostring).satisfieschecks a shape without losing specific literal information.
type Role = "guest" | "member" | "admin";
type RouteGuard = {
requiresAuth: boolean;
allowed: Role[];
};
const guards = {
"/": { requiresAuth: false, allowed: ["guest", "member", "admin"] },
"/account": { requiresAuth: true, allowed: ["member", "admin"] },
"/admin": { requiresAuth: true, allowed: ["admin"] }
} satisfies Record<string, RouteGuard>;
// guards["/admni"] would still be a runtime bug, but:
// - typos in the object shape are caught
// - values must match Role[]
// - you keep useful literal info for autocomplete
Mini checklist: when to type a const object
- Use
satisfiesto validate structure - Add
as constwhen you need literal keys/values preserved - Prefer enums only when you truly need runtime values
Step 6 — Learn narrowing (it’s the daily driver)
Most real-world TypeScript work is narrowing: turning “maybe” into “definitely” using checks the compiler understands. You’ll use this constantly with union types, optional properties, and unknown data.
Narrowing patterns you’ll actually use
typeof x === "string"x == nullto covernullandundefined"prop" in objfor object unions- Switch on a discriminator:
state.kind - User-defined type guards for tricky cases
What to avoid
- Blind casts (
as Something) for unvalidated data - Optional chaining everywhere instead of fixing types
- “Fixing” errors by widening to
any
Parse at the edge, trust inside. Once you validate input, your internal code can be clean and confidently typed.
Common mistakes
TypeScript pain is usually self-inflicted. These are the most common pitfalls that make TS feel “busy” or “fragile”, and the fixes that keep your types practical.
Mistake 1 — Using any at the boundary
External data is where your types matter most. any makes your code compile while quietly moving bugs to runtime.
- Fix: start unknown, narrow/parse, then work with a trusted type.
- Fix: type your API clients and environment accessors.
Mistake 2 — Modeling state with booleans and nullable fields
Booleans compose poorly (isLoading + hasError + data).
You end up with impossible states that only appear in production.
- Fix: use discriminated unions (
kindstates). - Fix: enforce exhaustiveness with
never.
Mistake 3 — Duplicating types for every layer
Copy/paste types drift. A field gets renamed in one place, not the other, and you’re back to stringly-typed chaos.
- Fix: define one canonical type and derive with
Pick/Omit. - Fix: keep DTOs explicitly derived (not rewritten).
Mistake 4 — Over-typing internal code (fighting inference)
Over-annotation increases maintenance cost and hides the real problem: weak typing at the edges.
- Fix: let locals infer; type public functions, models, and external inputs.
- Fix: use
satisfiesto validate objects without losing literals.
Mistake 5 — Using Partial as “anything goes”
Partial<T> is useful, but it can hide required fields and break invariants if used as a general-purpose “update type”.
- Fix: create a dedicated patch type: pick allowed fields, then make those optional.
- Fix: validate updates server-side and keep domain rules explicit.
Mistake 6 — Ignoring “index access” footguns
arr[i] can be undefined. So can map[key]. These are real-world sources of crashes.
- Fix: enable
noUncheckedIndexedAccess(and handle missing values). - Fix: prefer
Mapwhen you want explicitget()semantics.
If TypeScript is yelling, ask: “Did I encode my state correctly?” Fixing the model often removes the error without casts.
FAQ
Should I use type or interface in TypeScript?
Use whichever your team standardizes on, but reach for type when you need unions and compositions.
Use interface when you’re defining a public object shape that might be extended or merged. Consistency matters more than the “perfect” choice.
What TypeScript types give the biggest day-to-day value?
Unions (especially discriminated unions), literal types, and utility types like Pick/Omit/Record.
These reduce duplication, prevent impossible states, and make refactors safer with minimal overhead.
When should I use unknown instead of any?
Use unknown for any external input you haven’t validated.
It forces narrowing and prevents accidental property access. Reserve any for rare cases (like integrating untyped libraries) and isolate it behind wrappers.
Is as casting always bad?
No—casts are fine when you’re adding information the compiler can’t infer, after validation. They’re risky when used to skip validation (e.g., casting API JSON directly to a domain model). Prefer narrowing functions and parsers at the boundaries.
Why does TypeScript still let runtime bugs happen?
TypeScript checks types at compile time, not runtime. If you lie to the compiler (casts) or accept unvalidated external data, runtime can still break. The fix is to validate at boundaries and keep internal types trustworthy.
What’s the best way to type API responses?
Define response DTO types and keep domain models separate when needed. Treat API data as untrusted until validated/normalized, then convert into your internal model. This prevents “API drift” from silently corrupting core logic.
Cheatsheet
A fast scan you can bookmark. These are the TypeScript types you’ll actually use most weeks.
Daily patterns
- Literal unions for statuses:
"draft" | "published" - Discriminated unions for state machines:
{ kind: "loading" } - Pick/Omit to derive DTOs and view models
- Record for maps:
Record<Key, Value> - Narrowing via
typeof,in, and discriminators - unknown for untrusted input
High-signal compiler options
strict(baseline)noUncheckedIndexedAccess(prevents “undefined at runtime”)exactOptionalPropertyTypes(optional means optional)useUnknownInCatchVariables(safer error handling)noImplicitOverride(safer inheritance)
Utility types: what they mean in plain English
| Utility | Plain meaning | Typical use |
|---|---|---|
Pick<T, K> |
Keep only fields K from T | List cards, summary views |
Omit<T, K> |
Everything in T except K | Create payloads, remove server fields |
Partial<T> |
All fields optional | Patch updates (use carefully) |
Required<T> |
All fields required | Post-validation “now it’s complete” |
Record<K, V> |
Object map from keys to values | Lookup tables, config maps |
ReturnType<Fn> |
Return type of a function | Typed wrappers and adapters |
Parameters<Fn> |
Tuple of parameter types | Forwarding functions and middleware |
NonNullable<T> |
Remove null/undefined | After checks, enforce non-null |
If you’re about to write a second type that looks 90% like the first one, stop and try Pick/Omit/Record first.
Wrap-up
Practical TypeScript is not about showing off. It’s about making your codebase easier to change without fear. If you take only a few things from this post:
- Type the boundaries (external input, public APIs), then let inference handle the rest.
- Use unions (especially discriminated unions) to model state and eliminate impossible combinations.
- Derive types with utility types instead of copying shapes across layers.
- Avoid “turning off the compiler” with
anyand unvalidated casts.
Next actions (10 minutes)
- Enable (or tighten)
strictsettings in one package/module - Refactor one “loading/error/data” area into a discriminated union
- Replace one duplicated DTO with
Pick/Omit - Find one
as anyand move the validation to the edge
Keep this page bookmarked. The cheatsheet + the state-union pattern will pay for themselves the next time you do a refactor under pressure.
Quiz
Quick self-check (demo). This quiz is auto-generated for programming / typescript / typescript.