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
TypeScript in React
React components are functions, so the same typing principles apply to props, state, events, and reusable components.
A simple component can type its props directly:
type Product = {
id: number;
name: string;
price: number;
};
function ProductCard({ product }: { product: Product }) {
return <h2>{product.name}</h2>;
}
Generics become useful for reusable components. For example, a generic list can preserve the relationship between its item type and its render function:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
};
function List<T>({ items, renderItem }: ListProps<T>) {
return <>{items.map(renderItem)}</>;
}
This allows the same component to work with different data while keeping the item type available inside renderItem.
For API boundaries, prefer explicit domain types and avoid allowing any to spread through the application. If the external data is not trusted, unknown forces the application to validate or narrow it before use.
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.