TypeScript Fundamentals: Typing Properly
TypeScript adds a static type system on top of JavaScript, checked at compile
time (tsc) rather than at runtime. The goal isn't "add types everywhere" —
it's to let the compiler catch entire classes of bugs (wrong shape passed to
a function, typo'd property, undefined slipping through) before the code
ever runs.
Basic Types
let name: string = "Igor";
let age: number = 30;
let active: boolean = true;
let tags: string[] = ["python", "typescript"];
let point: [number, number] = [10, 20]; // tuple: fixed length, fixed types
enum Role {
Admin,
Editor,
Viewer,
}
Most of the time, explicit annotations on simple variables aren't needed — TypeScript's type inference figures it out from the assigned value:
Reach for explicit annotations where inference can't help: function
parameters, function return types on public APIs, and empty structures
(let items: string[] = [], since [] alone infers as any[]).
any, unknown, never, void
any— opts a value out of type checking entirely. It's the escape hatch; every operation on ananyis allowed and unchecked. Avoid it — it silently defeats the point of using TypeScript at all.unknown— also "could be anything," but safe: you can't do anything with anunknownvalue until you narrow it to a specific type first. Useunknownfor things like API responses orcatchclause errors, instead ofany.never— a function that never returns (always throws, or loops forever) returnsnever. Also shows up as the type of an unreachable branch, which is useful for exhaustiveness checks.void— a function that doesn't return a meaningful value.
function parseInput(data: unknown): string {
if (typeof data !== "string") {
throw new Error("expected a string"); // return type of this branch: never
}
return data; // narrowed to string here
}
Interfaces vs. Type Aliases
Both describe object shapes; the choice mostly comes down to conventions and a couple of real differences:
interface User {
id: number;
name: string;
email?: string; // optional property
readonly createdAt: Date; // can't be reassigned after creation
}
type UserId = number | string; // type aliases can name unions, interfaces can't
interfacesupports declaration merging (twointerface User {}declarations in scope combine into one) and reads naturally withextendsfor object inheritance — a common convention for public object/class shapes.typecan alias anything — unions, tuples, primitives, mapped types — not just object shapes, so it's the only option for things liketype Status = "pending" | "done" | "failed".
A reasonable default: interface for object shapes you expect to be
extended or implemented; type for unions, tuples, and anything that isn't
a plain object shape.
Function Typing
function add(a: number, b: number): number {
return a + b;
}
function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}`;
}
function log(message: string, code?: number): void {
console.log(code ? `[${code}] ${message}` : message);
}
Parameters and return types get annotated explicitly; TypeScript won't infer a function's parameter types from how it's called elsewhere.
Union & Intersection Types
type Id = number | string; // union: could be either
type Employee = Person & { salary: number }; // intersection: must satisfy both
Type Narrowing
TypeScript tracks control flow and narrows a variable's type inside conditional branches:
function formatId(id: number | string): string {
if (typeof id === "number") {
return id.toFixed(0); // id is `number` here
}
return id.toUpperCase(); // id is `string` here
}
Discriminated unions — a shared literal field used to narrow between variant shapes — are one of the most useful patterns in TypeScript:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // narrowed to the circle variant
case "rectangle":
return shape.width * shape.height; // narrowed to the rectangle variant
}
}
Generics
Generics let a function, interface, or class stay type-safe while working over more than one concrete type:
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // inferred as number | undefined
first(["a", "b"]); // inferred as string | undefined
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 42 };
Without the generic, first would either lose type information (any) or
need one copy per type. <T> keeps the relationship between input and output
types intact.
Useful Utility Types
Built into TypeScript, these transform existing types instead of writing new ones from scratch:
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = Partial<User>; // all properties optional
type UserPreview = Pick<User, "id" | "name">; // only id + name
type UserWithoutId = Omit<User, "id">; // everything except id
type UserMap = Record<number, User>; // { [key: number]: User }
type ReadonlyUser = Readonly<User>; // all properties readonly
Best Practices
- Enable
strictmode intsconfig.json("strict": true) — it turns onstrictNullChecks,noImplicitAny, and friends, which catch the majority of real bugs TypeScript is good at catching. - Prefer
unknownoveranyat boundaries (API responses,JSON.parseresults) and narrow before use. - Let inference do the work for local variables; annotate function signatures explicitly since they're the contract other code relies on.
- Model states with discriminated unions instead of optional fields plus
booleans (
{ status: "loading" } | { status: "success", data: T }beats{ loading: boolean, data?: T }).
Related Articles
- FastAPI Event Loop — the backend-side equivalent concern of knowing exactly what runs where.