Basic Types
TypeScript's built-in scalar types - string, number, boolean, null, undefined, symbol, bigint, any, unknown, never.
TypeScript adds static types on top of JavaScript's runtime types. Most annotations are optional - the compiler infers types from assignments - but knowing the full type vocabulary lets you annotate precisely when inference isn't enough.
The primitive types mirror JavaScript's built-in values. Type annotations appear after the variable name with a colon.
const name: string = "Alice";
const age: number = 30;
const active: boolean = true;
const nothing: null = null;
const missing: undefined = undefined;
const id: symbol = Symbol("id");
const big: bigint = 9007199254740993n;any opts a value out of type checking entirely - the compiler stops tracking it. unknown is the safer alternative: values are type-checked, but you must narrow the type before using them.
// any: no type checking - avoid in new code
let loose: any = "hello";
loose = 42; // no error
loose.toFixed(2); // no error - even if loose is a string
// unknown: must narrow before use
let raw: unknown = JSON.parse('{"x": 1}');
// raw.x ← error: Object is of type 'unknown'
if (typeof raw === "object" && raw !== null && "x" in raw) {
console.log((raw as { x: number }).x); // safe
}never is TypeScript's bottom type - a type with no values. It appears in two situations: functions that never return (they always throw or loop forever), and union branches that TypeScript has fully exhausted during narrowing.
The assertUnreachable helper is what makes never useful in practice. Its parameter type is never, so if TypeScript believes any value could reach that call, you get a compile error - not a runtime crash.
// A function that never returns has return type `never`
function fail(message: string): never {
throw new Error(message);
}
// x: never means "this branch is impossible - the compiler guarantees it"
function assertUnreachable(x: never): never {
return fail(`Unhandled case: ${JSON.stringify(x)}`);
}
type Direction = "north" | "south" | "east" | "west";
function move(dir: Direction): string {
switch (dir) {
case "north": return "moving north";
case "south": return "moving south";
case "east": return "moving east";
case "west": return "moving west";
default: return assertUnreachable(dir);
// ↑ dir is `never` here because all cases are handled.
// Add "northeast" to Direction and this line immediately errors.
}
}The real payoff shows up on a team. When you add a new variant to a shared union - a new event type, a new API status, a new feature flag - TypeScript errors at every unhandled switch across the entire codebase. You can't ship until every callsite is updated. never turns a runtime oversight into a compile-time requirement.
In production
Add assertUnreachable to utils/types.ts and import it in every switch. When a teammate adds a new union variant, every unhandled switch fails to compile - the bug is caught in CI, not in production.
type ApiEvent =
| { type: "success"; data: { id: string; name: string } }
| { type: "error"; code: number; message: string }
| { type: "loading" };
function handleEvent(event: ApiEvent): void {
switch (event.type) {
case "success":
renderUser(event.data);
break;
case "error":
showError(event.code, event.message);
break;
case "loading":
showSpinner();
break;
default:
assertUnreachable(event);
}
}
// A teammate adds `{ type: "cancelled" }` to ApiEvent.
// The default branch above errors immediately:
// Argument of type '{ type: "cancelled" }' is not
// assignable to parameter of type 'never'.
// No runtime bug. No forgotten branch. Caught at CI.In production
Ban any with @typescript-eslint/no-explicit-any from day one. Use unknown at every boundary where you don't control the shape - JSON.parse, API responses, message queues - and narrow before you use it. any lets bugs through silently; unknown makes the compiler ask you to prove it's safe first.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free