TypeScript has a rich type system, but you don't need to understand all of it to be productive. The reality is that 90% of the work is done by a handful of features you reach for every day. This is the practical reference — primitives, unions, generics, utility types, and the narrowing patterns that make it all click. No abstract theory; every example is the kind of code you write in a real project. If you know JavaScript well and have done some TypeScript but want to solidify your mental model, this is for you. The full story is in the TypeScript Handbook — this article is the 80/20 version.
Primitives and Literals
TypeScript's primitive types map directly to JavaScript's runtime types:
string, number, boolean, null,
undefined, bigint, and symbol. You'll use the first
three constantly; the rest come up in specific contexts.
// Primitives — the foundation
let userId: number = 42;
let username: string = 'alice';
let isActive: boolean = true;
let deletedAt: Date | null = null; // nullable pattern
let refreshToken: string | undefined; // optional patternWhere TypeScript gets interesting is literal types. Instead of just
saying a value is a string, you can say it must be one of a specific set of strings.
This is far more useful than a plain string annotation because TypeScript can
catch invalid values at compile time:
type Direction = 'north' | 'south' | 'east' | 'west';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;
function navigate(dir: Direction) {
console.log(`Moving ${dir}`);
}
navigate('north'); // ✅
navigate('up'); // ❌ Argument of type '"up"' is not assignable to type 'Direction'
// Combine with string for "known values plus free-form"
type EventName = 'click' | 'focus' | 'blur' | (string & {});(string & {}) trick at the end keeps autocomplete working for the
known values while still accepting any string. It's a common pattern in design system libraries.interface vs type — When to Use Which
This is the question every TypeScript beginner asks. The practical answer: use
interface for object shapes — especially ones that represent public API contracts or
that other types will extend. Use type for unions, intersections, mapped types,
and anything that isn't purely an object shape. In practice they're largely interchangeable for
object shapes — pick one convention and be consistent within a codebase.
// interface — object shapes, extensible via extends
interface User {
id: number;
email: string;
createdAt: Date;
}
interface AdminUser extends User {
role: 'admin';
permissions: string[];
}
// type — unions, intersections, aliases for anything
type ID = string | number;
type Nullable<T> = T | null;
// Intersection — combine two shapes (common for mixins / HOC props)
type WithTimestamps = {
createdAt: Date;
updatedAt: Date;
};
type UserRecord = User & WithTimestamps;
// type is required here — interface can't express a union of shapes
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string; code: number };One meaningful difference: interfaces support declaration merging —
you can declare the same interface twice and TypeScript merges the definitions. This is how
libraries augment global types (e.g. adding properties to Window). Types don't
merge; redeclaring a type is an error.
Union and Intersection Types
Union types (A | B) say "this value is either A or B". Intersection types
(A & B) say "this value is both A and B at the same time". Unions are
everywhere in real code — the most powerful pattern they enable is the
discriminated union, which is how you model data that can be in different
states without resorting to optional fields everywhere.
// Discriminated union — model API response states cleanly
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; retryable: boolean };
// TypeScript narrows the type inside each branch
function render<T>(state: FetchState<T>) {
switch (state.status) {
case 'idle': return '<p>Not started</p>';
case 'loading': return '<p>Loading...</p>';
case 'success': return `<pre>${JSON.stringify(state.data)}</pre>`;
case 'error': return `<p>Error: ${state.error}</p>`;
}
}
// Intersection — common in React/Angular for composing prop types
type ButtonBaseProps = {
label: string;
disabled?: boolean;
};
type IconButtonProps = ButtonBaseProps & {
icon: string;
iconPosition: 'left' | 'right';
};The discriminated union pattern relies on a discriminant — a literal
property (here status) that is unique to each variant. TypeScript uses that
property to narrow the full type inside conditional branches, giving you type-safe access to
the variant-specific fields like data or error.
Generics — The Part That Trips Everyone Up
The motivating problem: you want a function that works on multiple types, but using
any throws away all type information. Generics solve this — they're type
parameters, written in angle brackets, that let a function or interface work over a
family of types while keeping full type safety.
// Without generics — any loses all information
function first(arr: any[]): any {
return arr[0];
}
const x = first([1, 2, 3]); // x is 'any' — TypeScript can't help you here
// With generics — T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined ✅
const s = first(['a', 'b', 'c']); // s: string | undefined ✅The <T> syntax declares a type parameter. You can name it anything
— T is just the convention for a single generic type. When you call the function,
TypeScript infers T from the arguments, so you rarely need to write it explicitly.
Generic interfaces are just as useful for modelling API shapes:
// Generic interface — the API wrapper pattern every codebase has
interface ApiResponse<T> {
data: T;
meta: {
page: number;
totalPages: number;
totalItems: number;
};
}
interface User {
id: number;
name: string;
email: string;
}
// The response type is fully typed — no casting needed
async function fetchUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch('/api/users');
return res.json();
}
const response = await fetchUsers();
response.data[0].email; // ✅ TypeScript knows this is a string
response.meta.totalPages; // ✅ TypeScript knows this is a number
// Constrained generics — T must have an 'id' field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const user = findById(users, 42); // T inferred as Useras to get the
return type you want. Those are the two signs a generic would be cleaner.Utility Types You'll Actually Use
TypeScript ships a set of built-in utility types that transform existing types into new ones. They eliminate the need to manually duplicate or tweak type definitions. Here are the ones that come up constantly in real codebases:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
}
// Partial<T> — all properties become optional
// Perfect for PATCH/update payloads
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; ... }
async function updateUser(id: number, payload: Partial<User>) {
return fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
}
// Required<T> — all properties become required (opposite of Partial)
interface Config {
apiUrl?: string;
timeout?: number;
retries?: number;
}
type ResolvedConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }
// Pick<T, K> — keep only specified properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Use in list views where you only need the display fields
// Omit<T, K> — exclude specified properties
type PublicUser = Omit<User, 'role' | 'createdAt'>;
// Safe to expose in API responses
// Readonly<T> — prevents mutation (great for config and frozen state)
const config: Readonly<ResolvedConfig> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
// config.timeout = 10000; // ❌ Cannot assign to 'timeout' because it is a read-only property
// Record<K, V> — typed dictionary
type UserCache = Record<number, User>;
const cache: UserCache = {};
cache[42] = { id: 42, name: 'Alice', email: '[email protected]', role: 'user', createdAt: new Date() };
// Common pattern: mapping string keys to a known value shape
type FeatureFlags = Record<string, { enabled: boolean; rolloutPct: number }>;
// ReturnType<T> — infer the return type of a function
function createSession(userId: number) {
return {
token: crypto.randomUUID(),
userId,
expiresAt: new Date(Date.now() + 86_400_000)
};
}
type Session = ReturnType<typeof createSession>;
// { token: string; userId: number; expiresAt: Date }
// — no need to define the type separately and keep it in syncunknown vs any vs never
These three types confuse most developers until they understand what problem each one
solves. any is the escape hatch — it disables type checking entirely for that
value. It's useful when migrating JavaScript to TypeScript, but overuse defeats the purpose of
TypeScript. unknown is the type-safe alternative: the value could be anything,
but you must narrow it before you can do anything with it.
never is a type that can never occur — the bottom of the type hierarchy.
// any — type checking disabled, use sparingly
function dangerousTransform(input: any) {
return input.toUpperCase(); // TypeScript won't warn even if this crashes at runtime
}
// unknown — safe alternative, forces you to check before using
function safeTransform(input: unknown): string {
if (typeof input === 'string') {
return input.toUpperCase(); // ✅ narrowed to string inside this block
}
if (typeof input === 'number') {
return input.toFixed(2); // ✅ narrowed to number
}
throw new Error(`Cannot transform value of type ${typeof input}`);
}
// Common use: parsing untrusted data (API responses, localStorage, user input)
function parseConfig(raw: unknown): ResolvedConfig {
if (
typeof raw === 'object' &&
raw !== null &&
'apiUrl' in raw &&
typeof (raw as any).apiUrl === 'string'
) {
return raw as ResolvedConfig;
}
throw new Error('Invalid config format');
}
// never — the exhaustive switch pattern
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
default:
// If you add a new Shape variant and forget to handle it here,
// TypeScript will error: Type 'NewShape' is not assignable to type 'never'
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}Type Narrowing in Practice
Type narrowing is how TypeScript refines a broad type (string | number,
unknown, a discriminated union) to a specific type inside a conditional block.
The
TypeScript narrowing docs
cover every guard in depth — here are the patterns you'll write daily.
// typeof — primitive narrowing
function formatValue(val: string | number | boolean): string {
if (typeof val === 'string') return val.trim();
if (typeof val === 'number') return val.toLocaleString();
return val ? 'Yes' : 'No';
// TypeScript knows val must be boolean here
}
// instanceof — class/object narrowing
function handleError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return 'An unknown error occurred';
}
// in operator — property existence check
type Cat = { meow(): void };
type Dog = { bark(): void };
function makeNoise(animal: Cat | Dog) {
if ('meow' in animal) {
animal.meow(); // TypeScript narrows to Cat
} else {
animal.bark(); // TypeScript narrows to Dog
}
}
// Discriminated union narrowing (most common in component state)
type LoadState<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string };
function renderState<T>(state: LoadState<T>) {
if (state.status === 'success') {
console.log(state.data); // ✅ data is accessible
} else if (state.status === 'error') {
console.error(state.message); // ✅ message is accessible
}
}
// Type predicates — custom type guards
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'email' in obj
);
}
// After isUser() returns true, TypeScript knows obj is User
function processPayload(payload: unknown) {
if (isUser(payload)) {
console.log(payload.email); // ✅ fully typed
}
}The
typeof operator
is the most fundamental narrowing tool. Combined with
instanceof, the in operator, and discriminant property checks,
you can handle virtually any narrowing scenario without reaching for any or unsafe
casts.
Wrapping Up
TypeScript's type system rewards learning the fundamentals well over memorising
obscure features. Literal types and discriminated unions eliminate entire categories of runtime
bugs. Generics let you write reusable, type-safe abstractions. Utility types like
Partial, Pick, Omit, and ReturnType keep
your types DRY. And unknown with narrowing gives you the safety of types even at
the edges where data comes in from the outside world. The best way to solidify all of this is
to experiment interactively — the
TypeScript Playground
lets you paste any snippet and see the inferred types instantly, no setup needed.
When you're working with JSON data in TypeScript projects, the
JSON Formatter and
JSON to TypeScript tools on this site can generate
interface definitions from real API payloads automatically.