Structural typing, narrowing, generics, mapped and conditional types, enums vs unions, satisfies, classes, declaration files, tsconfig and the classic traps.
AMCompiled by Aditya Mishra · Technical Lead, BNP Paribas
The TypeScript type system on one page, from unknown to infer. Every snippet compiles with tsc --strict (checked against TypeScript 7.0).
Why TypeScript
TypeScript is JavaScript plus static types. Types are erased at compile time, so nothing is checked at runtime: validate external data (JSON, forms, env vars) yourself.
Type checking and emit are separate: tsc still emits JavaScript when there are type errors unless noEmitOnError is set. Bundlers such as esbuild and SWC strip types without checking them.
Structural typing: compatibility is decided by shape, not by name. Anything that has the required members fits.
interface and type exist only for the checker; class and enum create both a type and a runtime value.
Branded types simulate nominal typing when two strings must not mix (user IDs vs order IDs).
interface Point { x: number; y: number }class Pixel { constructor(public x: number, public y: number, public color = 'red') {} }const p: Point = new Pixel(1, 2); // ok: Pixel has x and y, extra members are finetype UserId = string & { readonly __brand: 'UserId' };const toUserId = (s: string) => s as UserId;function loadUser(id: UserId) {}loadUser('42'); // error: string is not a UserIdloadUser(toUserId('42')); // ok
TypeScript
Core & special types
Type
Holds
Use it for
string, number, boolean, bigint, symbol
primitives (lowercase; String is the wrapper object)
everyday values
null, undefined
only themselves, under strictNullChecks
“no value”: T | null or x?: T
any
anything; switches checking off and spreads
migration, last resort
unknown
anything, but must be narrowed before use
untrusted input, catch (e)
never
no value at all (bottom type, assignable to everything)
exhaustive checks, functions that always throw
void
“no useful return value”
function return types
object
any non-primitive
“some object”
{}
any value except null and undefined, primitives included
rarely what you mean
any vs unknown: both accept every value. With any you can do anything (x.foo.bar() compiles); with unknown you can do nothing until you narrow. unknown is the top type, never the bottom.
Without strictNullChecks, null and undefined are assignable to every type, which is the main reason to keep strict on.
void vs undefined: a function type() => void accepts functions that return a value (the result is ignored), so items.forEach((x) => list.push(x)) is fine.
never also shows up when narrowing removes every option and for impossible intersections: string & number is never.
Inference: let s = 'hi' is string, const s = 'hi' is the literal type 'hi'. Annotate parameters and exported APIs; let locals infer.
Type vs interface, unions & literals
interface
type alias
Object shapes
yes
yes
Unions, tuples, primitives, mapped and conditional types
no
yes
Extending
extends: conflicting members are an error
&: conflicting members become never
Declaration merging
yes: reopen it to add members
no: a duplicate name is an error
A class can implement it
yes
yes, if it is an object type (not a union)
Assignable to Record<string, unknown>
no (no implicit index signature)
yes
Rule of thumb: interface for object shapes and extendable public APIs, type for unions, tuples, function types and computed types. Consistency matters more than the choice (type vs interface).
type Status = 'idle' | 'loading' | 'error'; // union of literal typestype Timestamped = { createdAt: Date };type Post = { title: string } & Timestamped; // intersection: has bothconst ROLES = ['admin', 'editor'] as const; // readonly ['admin', 'editor']type Role = (typeof ROLES)[number]; // 'admin' | 'editor'const req = { method: 'GET' }; // { method: string }: widenedconst req2 = { method: 'GET' } as const; // { readonly method: 'GET' }
TypeScript
A | B: the value is one of them, so only members common to all are usable until you narrow. A & B: the value is both, so it has every member.
as const makes literals deeply readonly and stops widening: the standard way to derive a union from an array or object.
Narrowing
Technique
Example
Narrows to
typeof
typeof x === 'string'
primitives; 'object' still includes null
instanceof
e instanceof Error
class instances (prototype chain)
in
'swim' in pet
union members that declare the key
Equality, truthiness
x !== null, x != null, if (x)
drops null/undefined (truthiness also drops 0, '')
Discriminated union
switch (s.kind)
the member with that literal tag
Exhaustive check
const _: never = s
compile error when a case is missing
Type predicate
(x): x is Fish => ...
your own guard, usable in if and filter
Assertion function
asserts x is T, asserts cond
narrowed after the call (or it throws)
type Shape = { kind: 'circle'; r: number } | { kind: 'rect'; w: number; h: number };function area(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.r ** 2; // s: { kind: 'circle'; r: number } case 'rect': return s.w * s.h; default: { const unreachable: never = s; // error here if a new kind is added return unreachable; } }}
TypeScript
const isString = (x: unknown): x is string => typeof x === 'string';function assert(cond: unknown, msg = 'Assertion failed'): asserts cond { if (!cond) throw new Error(msg);}function shout(name: string | null) { assert(name !== null, 'name required'); return name.toUpperCase(); // name: string}const ids = ['a', undefined, 'b'].filter((x) => x !== undefined); // string[]
TypeScript
Predicates are trusted, not verified: a wrong x is T lies to the compiler.
Since TypeScript 5.5, simple predicates like the filter callback above are inferred; before that, ids was (string | undefined)[].
An arrow-function assertion needs an explicit type on the variable, or calling it fails with “Assertions require every name in the call target to be declared with an explicit type annotation”.
A narrowed let or parameter stays narrowed inside a callback only if it is not reassigned later (TS 5.4+). Otherwise copy it into a const first.
Generics
function first<T>(items: readonly T[]): T | undefined { return items[0]; }function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }const nm = getProp({ id: 1, name: 'Ada' }, 'name'); // stringgetProp({ id: 1 }, 'email'); // error: '"email"' is not '"id"'interface ApiResponse<T = unknown> { data: T; error?: string } // default type argumentfunction tuple<const T extends readonly unknown[]>(...xs: T): T { return xs; }const t = tuple('a', 1); // readonly ['a', 1]
TypeScript
Type parameters relate inputs to outputs. If T appears only once in a signature, you don’t need it: use unknown or the constraint.
<T extends X> is a constraint (T must be assignable to X), not inheritance.
Inference reads T from the arguments; pass it explicitly (useState<User | null>(null)) when the argument is not enough. It is all or nothing: you can’t pass some type arguments and infer the rest, except through defaults.
const type parameters (TS 5.0) infer literal types as if the caller wrote as const. NoInfer<T> (TS 5.4) stops one argument from driving inference.
const config = { host: 'localhost', port: 5432 };type Config = typeof config; // { host: string; port: number }type ConfigKey = keyof Config; // 'host' | 'port'type Port = Config['port']; // number (indexed access)type Optional<T> = { [K in keyof T]?: T[K] }; // like Partialtype Mutable<T> = { -readonly [K in keyof T]: T[K] }; // strips readonlytype Setters<T> = { [K in keyof T as `set${Capitalize<string & K>}`]: (v: T[K]) => void };type StringKeys<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };type S = StringKeys<Config>; // { host: string }
TypeScript
typeof in a type position reads a value’s type; keyof is the union of keys; T[K] looks up a property type; T[number] is an array or tuple’s element type.
keyof a type with a string index signature is string | number, because JavaScript turns numeric keys into strings.
Modifiers: readonly and ? add, -readonly and -? remove (+ is implied).
Key remapping with as (TS 4.1) renames keys; mapping a key to never drops it.
Mapped types over keyof T are homomorphic: they keep readonly and ?, and on tuples they produce tuples (Optional<[string, number]> is [string?, number?]).
Conditional & template types
type IsString<T> = T extends string ? true : false;type A = IsString<'a' | 1>; // boolean: distributes to true | falsetype IsStringStrict<T> = [T] extends [string] ? true : false;type B = IsStringStrict<'a' | 1>; // false: [T] turns distribution offtype ElementOf<T> = T extends readonly (infer E)[] ? E : never;type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;type Params<P> = P extends `${string}:${infer Name}/${infer Rest}` ? Name | Params<Rest> : P extends `${string}:${infer Name}` ? Name : never;type R = Params<'/users/:id/posts/:postId'>; // 'id' | 'postId'
TypeScript
T extends U ? X : Ydistributes over a union when T is a naked type parameter: it runs per member and unions the results.
never is the empty union, so a distributive conditional on never gives never: IsString<never> is never, not false.
infer X captures part of a type inside the extends clause; infer X extends string adds a constraint (TS 4.7).
Conditional types can recurse (DeepReadonly, the Params parser above); very deep recursion hits a compiler limit.
Template literal types build string types. A union in a slot yields the cross product: `${'a' | 'b'}-${'x' | 'y'}` has four members. Uppercase, Lowercase, Capitalize and Uncapitalize transform them.
Enums, const objects & satisfies
enum Direction { Up, Down } // numeric: Up = 0, Down = 1, reverse-mappedenum Level { Info = 'INFO', Warn = 'WARN' } // string: no reverse mappingconst up = Direction[0]; // 'Up'const lvl: Level = 'INFO'; // error: use Level.Infoconst Color = { Red: 'RED', Green: 'GREEN' } as const;type Color = (typeof Color)[keyof typeof Color]; // 'RED' | 'GREEN'function paint(c: Color) {}paint('RED'); // ok: plain strings workpaint(Color.Green); // ok
TypeScript
enum
union of literals
as const object
Runtime value
yes, an object
no (erased)
yes, a plain object
Accepts the raw value
numeric: any number variable; string: no
yes
yes
List members at runtime
Object.values (numeric ones include reverse keys)
no
Object.values(Color)
Erasable syntax
no
yes
yes
Erasable matters because Node’s built-in type stripping and the erasableSyntaxOnly flag (TS 5.8) reject syntax that emits code: enum, namespaces with values, parameter properties.
const enum members are inlined and no object is emitted, but tools that transpile one file at a time (isolatedModules) can’t inline enums declared in other files. Avoid them in libraries.
x: T checks and widens to T; x satisfies T (TS 4.9) checks but keeps the inferred type; x as T asserts with only a loose overlap check. as const satisfies T gives validated literal types.
Functions & classes
type Formatter = (value: number, locale?: string) => string; // function typeinterface Counter { (): number; reset(): void } // callable with propstype Ctor<T> = new (...args: any[]) => T; // construct signaturefunction parse(input: string): number; // overload signaturesfunction parse(input: string[]): number[];function parse(input: string | string[]): number | number[] { // implementation return Array.isArray(input) ? input.map(Number) : Number(input);}const one = parse('42'); // numberconst many = parse(['1', '2']); // number[]
TypeScript
Optional x?: T, default x = 1 (implies optional), rest ...xs: T[]. Optional parameters come after required ones.
Callers see only the overload signatures, never the implementation signature; list the most specific first. Prefer a union or generic when the return type doesn’t depend on the input.
A this parameter (function (this: HTMLElement, e: Event)) only types this and is erased.
public is the default, protected allows subclasses, readonly blocks reassignment after construction.
abstract classes can’t be instantiated; concrete subclasses must implement every abstract member.
implements only checks the class. It adds no members and doesn’t type method parameters: find(id) would still be an implicit any.
override marks overridden members; noImplicitOverride makes it mandatory. Under strictPropertyInitialization, fields need an initializer, a constructor assignment, or ! (el!: HTMLElement).
Decorators: TS 5.0 supports standard (TC39) decorators; experimentalDecorators switches to the older legacy version that some frameworks were built on.
readonly T[] (same as ReadonlyArray<T>) has no push or sort. A mutable array is assignable to a readonly one, not the reverse, so accept readonly parameters.
Without as const or an annotation, const pair = ['a', 1] is (string | number)[], not a tuple. Hooks that return tuples need as const.
With an index signature, every declared property must fit its value type, and obj[key] reads as V, not V | undefined, unless noUncheckedIndexedAccess is on.
Record<'a' | 'b', V> requires every key. Use Partial<Record<K, V>> for sparse objects and Map for dynamic keys with frequent adds and deletes.
// globals.d.ts: no top-level import/export, so everything here is globaldeclare module '*.svg' { const url: string; export default url; }declare module 'untyped-lib'; // shorthand: every import is `any`declare const __APP_VERSION__: string; // injected by the bundler
TypeScript
// augment.d.ts: it has an export, so `declare module` augments instead of replacingexport {};declare module 'express-serve-static-core' { interface Request { user?: { id: string; roles: string[] } }}declare global { interface Window { analytics?: { track(event: string): void } }}
TypeScript
.d.ts files contain only types. declare describes something that exists at runtime but wasn’t written in TypeScript.
Types ship inside the package (types in its package.json) or come from DefinitelyTyped: npm i -D @types/lodash.
Augmentation relies on interface merging. It only works in a module file; in a script file, declare module 'x' declares a new ambient module that replaces the real types.
import type and export type are always erased. verbatimModuleSyntax requires them for type-only imports, so the emitted imports are exactly what you wrote.
/// <reference types="vite/client" /> pulls in a package’s global types.
tsconfig essentials
Option
Effect
Typical
strict
the strict family: strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables and more
true
noUncheckedIndexedAccess
arr[i] and rec[k] include | undefined; not part of strict
true for new code
exactOptionalPropertyTypes
x?: T rejects an explicit undefined
optional
target
syntax level of the emitted JS; also picks the default lib
es2022 or later
lib
which built-in APIs exist (dom, es2023, …)
match the runtime
module
module format of the output
nodenext for Node, esnext or preserve with a bundler
moduleResolution
how import paths resolve
nodenext or bundler
esModuleInterop
import x from 'cjs-lib' works; implies allowSyntheticDefaultImports
true
isolatedModules, verbatimModuleSyntax
every file transpilable on its own (esbuild, SWC, Babel)
Defaults changed. The TypeScript 7.0 compiler this sheet was checked with defaults strict and esModuleInterop to true, and rejects target: es5, moduleResolution: node/node10/classic, baseUrl, outFile and AMD/UMD modules. TypeScript 5.x defaulted strict to false, so always set it explicitly.
Type props on the parameter: function Button({ variant = 'primary', ...rest }: ButtonProps). children is ReactNode. React.FC is optional and, since the React 18 types, doesn’t add children for you.
Hooks: useState<User | null>(null); useRef<HTMLInputElement>(null) gives RefObject<HTMLInputElement | null>; custom hooks that return tuples need as const.
Reuse props: ComponentPropsWithoutRef<'input'> for native elements, ComponentProps<typeof Button> for components.
Libraries: derive types from runtime schemas (type User = z.infer<typeof UserSchema> in Zod); res.json() returns Promise<any>, so validate it; Express handlers are generic, as in Request<Params, ResBody, ReqBody>.
type Events = { login: { userId: string }; logout: undefined };declare function on<K extends keyof Events>(type: K, fn: (payload: Events[K]) => void): void;on('login', (p) => console.log(p.userId)); // p: { userId: string }on('signup', () => {}); // error: not a key of Events
TypeScript
Best practices & debugging
Turn on strict (and noUncheckedIndexedAccess) on day one; migrating later is far more painful. Lint against any (@typescript-eslint/no-explicit-any).
Validate at the boundaries (unknown plus a schema), then trust the types inside.
Model state as discriminated unions ({ status: 'loading' } | { status: 'ok'; data: T }) instead of optional flags that allow impossible combinations.
Keep one source of truth: derive types with typeof, keyof, ReturnType or z.infer instead of copying them.
Prefer unknown to any, satisfies to as, literal unions to enums, and readonly inputs.
Use // @ts-expect-error rather than // @ts-ignore: it fails once the error is gone.
Debugging: hover in the editor to see inferred types; run tsc --noEmit in CI; --traceResolution explains “Cannot find module”; --explainFiles shows why a file is included; --noErrorTruncation prints full types.
Quick answers
any vs unknown? Both accept everything; unknown forces a check before use, any turns checking off.
type vs interface? Both describe objects; interfaces merge and extend, types also cover unions, tuples and computed types.
What is never? The type with no values: impossible branches, exhaustive checks, functions that never return.
Structural or nominal? Structural: same shape means compatible. Brands fake nominal types.
What does as const do? Infers the narrowest literal types and makes the value deeply readonly.
satisfies vs an annotation? Both check; satisfies keeps the narrower inferred type.
Is as a cast? No. It changes only the compile-time type; nothing converts at runtime.
Discriminated union? Members share a literal tag (kind); checking the tag narrows to one member.
Generic constraint?T extends X limits T to types assignable to X, so X’s members are usable.
What does infer do? Captures a type inside a conditional type, e.g. a promise’s value type.
Why is Object.keysstring[]? Structural typing lets an object carry more keys than its type lists.
Enum or union? Usually a literal union or an as const object: no runtime cost, erasable, plain strings work.
What does strict do? Enables the strict family, most importantly strictNullChecks and noImplicitAny.
What is a .d.ts file? Type declarations for JavaScript code, shipped with the package or via @types/*.
Do types exist at runtime? No, they’re erased, so validate untrusted data at runtime.
Gotchas & traps
Excess property checks apply only to fresh object literals: const o: Opts = { name: 'a', nmae: 'b' } errors, but assigning a variable with the same extra key compiles.
Object.keys(obj) is string[], not (keyof T)[] (likewise for...in and Object.entries), because the object may have extra keys. Assert as (keyof T)[] only when you’re sure.
Type assertions aren’t casts: '5' as unknown as number is still a string at runtime. JSON.parse returns any, which spreads silently.
Method parameters are bivariant: interface H { handle(a: Animal): void } accepts { handle(d: Dog) {} }. The property form handle: (a: Animal) => void is checked strictly under strictFunctionTypes.
Enum pitfalls: numeric enums accept any number variable and are reverse-mapped, so Object.keys(Direction) is ['0', '1', 'Up', 'Down']; string enums reject the equal plain string; const enum breaks file-by-file transpilers.
implements doesn’t type parameters, and typeof x === 'object' leaves null in the type.
Array.isArray narrows a readonly string[] union member to any[].
{} isn’t “empty object”: it accepts 0 and 'x'. Use Record<string, never> for an empty object, object for any non-primitive.
x?: T vs x: T | undefined: the first may be missing, the second must be present (even if undefined).
readonly and Readonly<T> are shallow and compile-time only; Object.freeze is the runtime version.