Guide
TypeScript fundamentals explained
TypeScript is JavaScript with an optional static type system layered
on top. You write .ts or .tsx files, the TypeScript compiler
(tsc) checks shapes and contracts at build time, and the output is plain
JavaScript browsers and Node.js already run. The payoff is not academic purity — it is
catching entire classes of bugs (wrong property names, missing null checks, mismatched
API payloads) before they reach production, while your editor surfaces autocomplete and
inline documentation. This guide covers the core type vocabulary, the patterns teams
actually use in
modern web apps
and
REST APIs,
and how to adopt TypeScript incrementally without freezing your codebase in
any escape hatches.
Why TypeScript exists
JavaScript is dynamically typed: a variable can hold a number at one moment and a
string the next. That flexibility speeds up early prototyping but scales poorly.
Refactoring a 40-field API response object by hand is error-prone; a typo in
user.emial fails silently until a user reports a blank screen. TypeScript
adds structural typing — types describe the shape of values, and the
compiler rejects code that violates those shapes.
Crucially, TypeScript types are erased at runtime. There is no
performance penalty in the browser beyond whatever bundler you already use. Types exist
only during development and CI. That is why TypeScript pairs naturally with
CI pipelines that
run tsc --noEmit on every pull request: broken contracts fail the build
instead of shipping to users.
When TypeScript is worth the cost
Small scripts and throwaway prototypes may not need types. Once multiple developers
touch the same modules, or you integrate with external HTTP and database schemas,
the upfront typing cost pays back quickly. Teams shipping React, Vue, Svelte, or
Node backends overwhelmingly standardize on TypeScript because the ecosystem ships
first-class type definitions (@types/* on npm) for popular libraries.
Core types and annotations
Primitive types mirror JavaScript: string, number,
boolean, bigint, symbol, plus
null and undefined. Arrays are written
string[] or Array<string>. Tuples fix arity and
position: [string, number] is a pair, not a general array.
You annotate variables explicitly when inference is insufficient:
let port: number = 3847;
const labels: readonly string[] = ["draft", "published"];
function formatLamports(lamports: bigint): string {
return (Number(lamports) / 1e9).toFixed(9);
}
TypeScript often infers types from initializers — if you write
const x = 42, x is number without an
annotation. Prefer inference for locals; annotate function parameters and public API
boundaries where callers need a contract.
Unions, literals, and optional fields
A union expresses “one of these”: type Status = "pending" |
"settled" | "failed". String literal unions replace fragile string constants.
Optional properties use ?: email?: string means the field
may be absent (typed as string | undefined).
unknown is the type-safe counterpart to any. Values typed
as unknown must be narrowed before use — ideal for JSON parsed from
cross-origin
fetch responses where you cannot trust the wire format blindly.
Interfaces vs type aliases
Both describe object shapes. Interfaces are extendable and merge when declared twice (useful for augmenting third-party modules). Type aliases can name unions, intersections, and mapped types interfaces cannot express as cleanly.
interface User {
id: string;
displayName: string;
role: "admin" | "member";
}
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
For plain data objects, either works — pick one style per project and stay consistent.
Reach for type aliases when modeling discriminated unions (the ok flag above
lets TypeScript narrow data vs error inside
if (result.ok) branches). That pattern mirrors how well-designed
REST error responses
separate success and failure payloads.
Generics: reusable type parameters
Generics let you write functions and types that work across shapes without falling back
to any. A fetchJson<T>(url) helper can return
Promise<T> while callers supply the expected schema:
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as T; // caller owns validation
}
interface Balance { lamports: number; }
const bal = await fetchJson<Balance>("/api/balance");
Generics appear everywhere in utility types: Record<string, number>,
Partial<User> for patch updates, Pick<User, "id">
for DTOs. Libraries like React use them heavily (useState<number>).
The mental model is simple: generics are function parameters for types.
Constraints
You can bound generics: <T extends { id: string }> ensures
T always has an id. That prevents calling
sortById on arbitrary objects while still accepting any type with a
string id field.
Type narrowing and control flow
TypeScript tracks how runtime checks refine types inside branches. After
if (typeof x === "string"), x is a string in
that block. instanceof, in checks, and user-defined
type guards (function isUser(v: unknown): v is User)
let you parse untrusted input safely.
Discriminated unions are the idiomatic pattern for state machines and
API results: a shared literal field (kind, type,
ok) tells the compiler which other fields exist. Exhaustiveness checking
with switch and a final never arm catches missing cases when
you add a new variant later — a refactor safety net plain JavaScript lacks.
Nullability and strict mode
With strictNullChecks enabled (part of "strict": true in
tsconfig.json), null and undefined are not
assignable to other types unless you explicitly allow them. That forces you to handle
absent values instead of assuming every object field exists — a common source of
production crashes in wallet and payment UIs where RPC responses may be partial during
network errors.
Project setup and compiler options
A minimal Node or Vite project needs typescript as a dev dependency, a
tsconfig.json, and a build step (tsc, esbuild,
or bundler integration). Key options:
strict: true— enables the full safety bundle; turn this on for new projects.noUncheckedIndexedAccess— array and record index lookups include| undefined; catches off-by-one assumptions.moduleResolution: "bundler"— matches modern ESM tooling (Vite, Next.js App Router).paths— alias imports like@/components(your bundler must mirror the same map).
Run the compiler in CI even if you transpile with esbuild or SWC for speed — those
tools strip types without validating them. tsc --noEmit is the canonical
typecheck-only command and belongs beside your unit tests in every pipeline.
Incremental adoption in JavaScript codebases
Rename .js to .ts file by file. Enable
allowJs and checkJs optionally for JSDoc-typed legacy
modules. Avoid mass @ts-ignore — each ignore is debt. Prefer
unknown plus a narrowing function at integration boundaries (third-party
scripts, JSON columns from
SQL queries) over
sprinkling any.
TypeScript in frontend and backend
On the frontend, TypeScript types props, hooks, and event handlers in component frameworks. It does not replace runtime validation for form input — pair typed models with schema validators (Zod, Valibot) at API boundaries so server and client agree on shapes. Typed components also improve accessibility work: discriminated unions for button vs link props prevent invalid attribute combinations at compile time.
On the backend, TypeScript models request bodies, database rows, and
queue messages. Share types via a monorepo package or OpenAPI-generated clients so your
frontend does not drift from the API. For high-throughput services, compile to
JavaScript with tsc or bundle with esbuild; for serverless, tree-shake
unused code and keep cold-start bundles small.
Common pitfalls
- Overusing
any— defeats the purpose; useunknownand narrow. - Trusting
ascasts — assertions bypass checking; validate external data at runtime. - Ignoring strict mode — loosening compiler flags to greenwash CI hides real bugs.
- Types without tests — types prove consistency, not behavior; still test business logic.
- Duplicated DTOs — generate or share types between client and server instead of copy-pasting interfaces.
Key takeaways
- TypeScript adds compile-time structure to JavaScript without changing runtime behavior — types erase after build.
- Master unions, interfaces, generics, and narrowing before advanced mapped types; they cover most day-to-day code.
- Enable
strictand runtsc --noEmitin CI alongside your test suite. - Model API success and failure with discriminated unions; pair static types with runtime validators at system boundaries.
- Adopt incrementally — typed modules compound safety as your web app or API surface grows.
Related reading
- REST API design explained — resource shapes, status codes, and error payloads that TypeScript interfaces should mirror
- SSR vs CSR vs SSG vs ISR explained — how typed React and Next.js apps choose rendering strategies
- CI/CD pipelines explained — wire typecheck gates, lint, and test stages before deploy
- CORS explained — typed fetch wrappers still need correct credentials and origin headers