TypeScript — Cheat Sheet
JavaScript & TypeScript · 12 topics. Download the PDF or the Instagram carousel and share it.
Why TypeScript, and How It Runs
TypeScript is a checker, not a runtime. It erases to JavaScript and disappears — which explains both what it catches and everything it cannot.
- ✓TypeScript checks then erases — the shipped JavaScript contains no type information
- ✓Because types are erased you cannot use instanceof on an interface or check a type at runtime
- ✓strict: true enables strictNullChecks and noImplicitAny, which is where most of the value lives
- ✓"as" is an assertion you make to the compiler, not a check the compiler performs
- ✓Data from the network is unverified — use a runtime validator such as Zod at the boundary
// TypeScript
interface User { id: number; name: string }
function greet(user: User): string {
return `Hi ${user.name}`
}
// Compiled JavaScript — the interface does not exist at runtime
function greet(user) {
return `Hi ${user.name}`
}
// Which means this is impossible:
if (value instanceof User) { } // Error: 'User' only refers to a type
// And this compiles, then explodes at runtime:
const data = await res.json() as User // 'as' is a claim, not a check
data.name.toUpperCase() // undefined if the API changedBasic Types, Inference and Annotations
Annotate function boundaries and let inference handle the rest. Over-annotating is a common beginner habit that makes code noisier without making it safer.
- ✓TypeScript has one number type — no int, long, float or double distinction
- ✓Annotate function parameters and exported return types; let everything else infer
- ✓any disables checking entirely; unknown forces you to narrow before use and is the safe alternative
- ✓const infers a literal type while let widens to the primitive, which matters for unions
- ✓Callback parameters are contextually typed from the surrounding signature, so they rarely need annotations
let name: string = 'Akshay'
let count: number = 42 // one number type — no int/long/float
let big: bigint = 9007199254740993n
let ok: boolean = true
let nothing: null = null
let missing: undefined = undefined
let key: symbol = Symbol('id')
// Arrays and tuples
let scores: number[] = [90, 85]
let pair: [string, number] = ['arrays', 12] // fixed length and order
let named: [x: number, y: number] = [1, 2] // labels, for readability
// any switches off checking; unknown keeps it on
let a: any = getValue()
a.whatever.deeply.nested // compiles, may explode
let u: unknown = getValue()
u.whatever // Error — must narrow first
if (typeof u === 'string') u.toUpperCase() // now allowed
// never — the type with no values; the return type of a function that throws
function fail(msg: string): never { throw new Error(msg) }Interfaces, Type Aliases and Structural Typing
TypeScript compares shapes, not names. Two unrelated types with the same members are interchangeable — the opposite of how Java works.
- ✓TypeScript is structurally typed — a value fits a type if it has the right shape, with no declared relationship
- ✓Fresh object literals get an excess-property check that variables do not, which catches option typos
- ✓Only type aliases can express unions, tuples and mapped types; only interfaces merge across declarations
- ✓readonly and optional are compile-time only — Object.freeze is the runtime equivalent
- ✓Record<Level, number> over an index signature when the keys are a known set, because missing keys are then errors
interface Point { x: number; y: number }
class Vector { constructor(public x: number, public y: number) {} }
function length(p: Point) { return Math.hypot(p.x, p.y) }
length(new Vector(3, 4)) // fine — Vector has x and y
length({ x: 3, y: 4 }) // fine — the literal has x and y
// In Java, Vector would have to declare 'implements Point'.
// Extra properties are allowed... except on fresh object literals
const v = { x: 3, y: 4, z: 5 }
length(v) // OK — v is a variable
length({ x: 3, y: 4, z: 5 }) // Error — excess property check
// The literal check exists to catch typos in options objects.Unions, Narrowing and Discriminated Unions
Unions model "one of these", and narrowing is how the compiler follows your checks. Discriminated unions are the single most useful pattern in the language.
- ✓A union value cannot be used until narrowed; typeof, in, instanceof and equality checks all narrow
- ✓Truthiness checks exclude 0 and empty string too — use != null when those are valid values
- ✓A custom type guard is a function returning `x is T`, for checks the built-ins cannot express
- ✓A discriminated union has a shared literal tag, letting the compiler know which fields exist in each branch
- ✓Assigning the value to `never` in the default branch makes forgetting a new case a compile error
function format(value: string | number | null) {
if (value === null) return '—' // value: string | number after this
if (typeof value === 'string') return value.trim() // string here
return value.toFixed(2) // number — the only thing left
}
// The narrowing tools
typeof x === 'string'
x instanceof Date
'role' in user // property presence
Array.isArray(x)
x === null / x !== undefined
x?.length // optional chaining narrows too
// Truthiness narrows, and has the classic trap
if (count) { } // excludes 0 as well as undefined — usually a bug
if (count != null) { } // excludes null AND undefined, keeps 0
// A custom type guard, for when the built-ins cannot express it
function isProblem(x: unknown): x is Problem {
return typeof x === 'object' && x !== null && 'slug' in x
}
if (isProblem(data)) data.slug // narrowed by the return typeGenerics
Generics carry a type through a function instead of losing it. Familiar from Java, with two differences worth knowing: inference is much stronger, and there is no erasure workaround because there is nothing to work around.
- ✓A generic preserves the relationship between input and output types instead of collapsing to any
- ✓TypeScript infers type arguments from the call site, so explicit <T> is rarely needed
- ✓extends constrains a type parameter, which is what allows you to access members on it
- ✓K extends keyof T is the pattern for type-safe property access by name
- ✓A type parameter appearing only once in a signature is doing nothing — use unknown or the concrete type
// Loses the type
function firstAny(arr: any[]): any { return arr[0] }
const n = firstAny([1, 2, 3]) // any — no help downstream
// Keeps it
function first<T>(arr: T[]): T | undefined { return arr[0] }
const m = first([1, 2, 3]) // number | undefined — inferred, no <number> needed
// Two parameters, related
function mapValues<T, U>(obj: Record<string, T>, fn: (v: T) => U): Record<string, U> {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, fn(v)]),
) as Record<string, U>
}
// A typed fetch wrapper — the pattern you will actually write
async function api<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) throw new Error(res.statusText)
return res.json() as Promise<T>
}
const problems = await api<Problem[]>('/api/problems')Utility Types
Partial, Pick, Omit, Record, ReturnType and friends. Deriving types from other types is what keeps them in sync when the source changes.
- ✓Derive types from a single source rather than duplicating shapes — renames then break at compile time
- ✓Partial, Pick, Omit and Record cover most everyday derivations; Omit<User, "id"> is the creation payload
- ✓ReturnType, Parameters and Awaited extract types out of existing functions and promises
- ✓typeof on a value yields its type, bridging the value world and the type world
- ✓as const preserves literals, and typeof arr[number] turns a constant array into a union — usually better than an enum
interface User {
id: number
name: string
email: string
role: 'student' | 'admin'
}
Partial<User> // every field optional — PATCH payloads
Required<User> // every field required
Readonly<User> // every field readonly
Pick<User, 'id' | 'name'> // { id, name }
Omit<User, 'id'> // everything except id — creation payloads
Record<'easy' | 'hard', number> // { easy: number; hard: number }
// Realistic use
type CreateUser = Omit<User, 'id'>
type UpdateUser = Partial<Omit<User, 'id'>>
type UserCard = Pick<User, 'name' | 'role'>
// Rename a field on User and every one of these updates with it.
// Hand-written duplicates would silently drift.Advanced Types — Mapped, Conditional and Template Literal
The type system is itself a small functional language. You will read far more of this than you write, and knowing the syntax is what makes library types comprehensible.
- ✓Mapped types iterate over keyof T and transform each key; -readonly and -? remove modifiers
- ✓`as` inside a mapped type remaps key names, and mapping a key to never removes it
- ✓Conditional types are ternaries over types; infer captures a type from a position
- ✓A conditional type distributes over a union unless you wrap both sides in a tuple
- ✓Template literal types build string unions — useful for routes and event names, but keep them readable
type Partial<T> = { [K in keyof T]?: T[K] }
type Readonly<T> = { readonly [K in keyof T]: T[K] }
type Nullable<T> = { [K in keyof T]: T[K] | null }
// Removing modifiers with minus
type Mutable<T> = { -readonly [K in keyof T]: T[K] }
type Concrete<T> = { [K in keyof T]-?: T[K] }
// Remapping keys with 'as'
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type UserGetters = Getters<{ name: string; age: number }>
// { getName: () => string; getAge: () => number }
// Filtering keys — map to never to drop them
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K]
}Classes and OOP in TypeScript
Everything Java gives you is here — access modifiers, abstract classes, interfaces — but the surrounding culture uses far fewer classes, and knowing when not to reach for one matters.
- ✓TypeScript classes support private, protected, readonly, abstract and implements, much as Java does
- ✓Parameter properties declare and assign a field in the constructor signature
- ✓private is erased at compile time and reachable at runtime; # is genuine encapsulation and is not serialised
- ✓A # private field breaks structural compatibility — two classes with one are never interchangeable
- ✓Prefer a module of functions or a factory closure over a class that only groups stateless methods
abstract class Repository<T> {
protected constructor(protected readonly db: Db) {}
abstract findById(id: string): Promise<T | null>
async requireById(id: string): Promise<T> {
const found = await this.findById(id)
if (!found) throw new NotFoundError(id)
return found
}
}
class ProblemRepo extends Repository<Problem> implements Searchable {
// parameter properties — declares and assigns in one line
constructor(db: Db, private readonly cache: Cache) { super(db) }
async findById(id: string) { ... }
#secret = 'value' // true runtime privacy (JS private field)
private soft = 'value' // compile-time only — visible at runtime
}
// static, getters, and index signatures all work as expected
class Config {
static readonly VERSION = '2.0'
get isProd() { return process.env.NODE_ENV === 'production' }
}TypeScript with React
Typing props, state, events, refs and children. This is where most TypeScript in a frontend job actually lives.
- ✓Type props with a plain interface and annotate the parameter — React.FC adds an implicit children prop
- ✓Extend React.ComponentPropsWithoutRef<"button"> to inherit every native prop on a wrapper component
- ✓useState infers from the initial value, so annotate only when it starts null or as an empty array
- ✓useRef<HTMLInputElement>(null) for DOM refs; access through optional chaining
- ✓Return `as const` from a custom hook so the result is a tuple, and throw in the context hook to narrow away undefined
interface ProblemCardProps {
problem: Problem
locked?: boolean
onOpen: (slug: string) => void
children?: React.ReactNode
}
export function ProblemCard({ problem, locked = false, onOpen, children }: ProblemCardProps) {
...
}
// Avoid React.FC — it adds an implicit children prop and complicates generics.
// A plain annotated parameter is clearer.
// Extending native element props — the pattern for design-system components
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
variant?: 'primary' | 'ghost'
}
export function Button({ variant = 'primary', ...rest }: ButtonProps) {
return <button className={styles[variant]} {...rest} />
}
// Now className, disabled, onClick, aria-* all typecheck for free.
// ReactNode = anything renderable. ReactElement = specifically an element.Typing the API Boundary
The types at the network edge are a claim, not a fact. Validating there — and generating types from the backend rather than hand-writing them — is what keeps a full-stack app honest.
- ✓`as` at the network boundary is an unverified claim — validate with a schema so the type and the check share one definition
- ✓z.infer derives the static type from the runtime schema, so they can never disagree
- ✓Generate client types from the server OpenAPI schema and check for drift in CI rather than hand-maintaining them
- ✓fetch does not reject on 4xx/5xx — model API failures with a typed error or a Result union
- ✓Dates arrive as ISO strings over JSON; type the wire shape honestly and convert once at the boundary
import { z } from 'zod'
const ProblemSchema = z.object({
slug: z.string(),
title: z.string(),
difficulty: z.enum(['easy', 'medium', 'hard']),
tags: z.array(z.string()),
hints: z.array(z.string()).nullable(),
})
type Problem = z.infer<typeof ProblemSchema> // the type, derived
export async function getProblem(slug: string): Promise<Problem> {
const res = await fetch(`/api/problems/${slug}`)
if (!res.ok) throw new ApiError(res.status, await res.text())
return ProblemSchema.parse(await res.json()) // throws on mismatch
}
// Compare: 'as Problem' claims the shape and fails 200 lines later
// with "cannot read property of undefined", far from the real cause.
// safeParse when a failure is expected and handled
const result = ProblemSchema.safeParse(input)
if (!result.success) return { error: result.error.flatten() }Migrating and Living With TypeScript
How to add TypeScript to an existing codebase without a rewrite, and how to keep any from spreading once it is in.
- ✓Adopt incrementally: allowJs with strict off, convert leaf modules first, then enable strict flags one at a time
- ✓@ts-check with JSDoc types checks a .js file without renaming it
- ✓any spreads to every value it touches; unknown at the boundary stops the spread
- ✓@ts-expect-error is preferable to @ts-ignore because it errors once the underlying problem is fixed
- ✓Bundlers strip types without checking them — tsc --noEmit in CI is what actually enforces the types
// 1. Add TypeScript with the loosest possible config
{ "compilerOptions": { "allowJs": true, "strict": false, "noEmit": true } }
// 2. Convert leaf-first: utilities and types before components.
// A typed module makes everything importing it better; a typed
// component with untyped dependencies fights inference.
// 3. Turn on strict flags one at a time, fixing as you go
"noImplicitAny": true // usually the largest single batch
"strictNullChecks": true // usually the most valuable
// 4. Check JS files without renaming them
// @ts-check at the top of a .js file, with JSDoc types:
/** @param {string} slug @returns {Promise<Problem>} */
async function getProblem(slug) { ... }
// 5. Ratchet: never let the error count go up.
// tsc --noEmit in CI, and each PR must not add errors.TypeScript on the Server
Node with TypeScript — module resolution, environment variables, and where a typed backend genuinely pays off in a full-stack app.
- ✓"type": "module" plus module: NodeNext is the modern setup; relative ESM imports need the output .js extension
- ✓__dirname does not exist in ESM — derive it from import.meta.url
- ✓Every process.env value is string | undefined; parse and coerce the whole environment once at startup
- ✓tsx and node --experimental-strip-types run TypeScript without checking it, so keep tsc --noEmit in CI
- ✓A shared schema package makes a renamed field a compile error on both ends — the main reason to run TypeScript on the server
// package.json
{ "type": "module" } // .js files are ESM
// without it, .js is CommonJS
// tsconfig for modern Node
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"types": ["node"]
}
}
// With NodeNext, relative ESM imports need the extension —
// and it is the OUTPUT extension, which trips everyone up:
import { score } from './scoring.js' // even though the file is .ts
// Running it
node --experimental-strip-types src/index.ts // Node 22+
tsx src/index.ts // the common dev tool
// Both strip types without checking — still run tsc --noEmit separately.
// __dirname does not exist in ESM:
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))