Basic Types and Type Annotations
Every Value Has a Type
In JavaScript, every value already has a type at runtime — typeof "hello" is "string", typeof 42 is "number". TypeScript's job is to track these types before runtime so you catch bugs in the editor instead of production.
The key insight most tutorials skip: TypeScript's type inference is so good that you should write fewer annotations, not more. Over-annotating is a code smell — it means you don't trust the compiler or don't understand what it already knows.
Think of TypeScript's type checker as a shadow that follows your code. Every variable, parameter, and return value casts a type shadow. Sometimes you need to tell TypeScript what shape the shadow should be (annotation). But most of the time, TypeScript can see the value and figure out the shadow on its own (inference). Good TypeScript means annotating boundaries — function parameters, return types for public APIs — and letting inference handle everything inside.
Primitive Types
TypeScript has seven primitive types matching JavaScript's runtime types:
// The seven primitives
const name: string = "Alice";
const age: number = 30; // No int/float distinction — all numbers are IEEE 754 doubles
const isActive: boolean = true;
const id: bigint = 9007199254740993n; // Arbitrary precision integers
const key: symbol = Symbol("key");
const empty: null = null;
const missing: undefined = undefined;
But you almost never need these annotations. TypeScript infers them:
// Inferred — no annotations needed
const name = "Alice"; // type: "Alice" (string literal type, not just string)
const age = 30; // type: 30 (number literal type)
const isActive = true; // type: true (boolean literal type)
let count = 0; // type: number (let widens to the base type)
let label = "hello"; // type: string (let widens to the base type)
Notice the difference between const and let inference. const infers literal types ("Alice", 30, true) because the value can never change. let infers widened types (string, number, boolean) because the value might change later. This distinction matters for discriminated unions and literal type narrowing.
Arrays
Two syntaxes, same result:
const numbers: number[] = [1, 2, 3];
const names: Array<string> = ["Alice", "Bob"];
// Inferred — prefer this
const scores = [90, 85, 92]; // type: number[]
const mixed = [1, "hello", true]; // type: (string | number | boolean)[]
// Readonly arrays — prevents push, pop, splice
const frozen: readonly number[] = [1, 2, 3];
frozen.push(4); // ERROR: Property 'push' does not exist on type 'readonly number[]'
// Tuples — fixed-length arrays with position-specific types
const pair: [string, number] = ["age", 30];
const [key, value] = pair; // key: string, value: number
The readonly array trap in function signatures
If you type a parameter as readonly number[], callers can pass both number[] and readonly number[]. But if you type it as number[], callers can only pass mutable arrays. This means readonly in parameter types is more permissive, not less:
function sum(nums: readonly number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
const mutable = [1, 2, 3]; // number[]
const immutable: readonly number[] = [1, 2, 3]; // readonly number[]
sum(mutable); // OK — number[] is assignable to readonly number[]
sum(immutable); // OK
function mutate(nums: number[]): void {
nums.push(4);
}
mutate(mutable); // OK
mutate(immutable); // ERROR — readonly number[] is not assignable to number[]Use readonly in parameters when you don't mutate the array. It accepts more input types and signals intent.
Objects
Object types define the shape of an object:
// Object type annotation
const user: { name: string; age: number; email?: string } = {
name: "Alice",
age: 30,
};
// But you'd normally use an interface or type alias (next topic)
interface User {
name: string;
age: number;
email?: string; // Optional property
}
const user2: User = { name: "Bob", age: 25 };
// Index signatures for dynamic keys
interface StringMap {
[key: string]: number;
}
const scores: StringMap = { alice: 95, bob: 87 };
Function Types
Function parameters require annotations. Return types are inferred but worth annotating on public APIs:
// Parameter annotations required — TypeScript can't infer parameter types
function add(a: number, b: number): number {
return a + b;
}
// Return type inferred — but explicit for public APIs
function greet(name: string) {
return `Hello, ${name}`; // Return type inferred as string
}
// Function type expressions
type MathOp = (a: number, b: number) => number;
const multiply: MathOp = (a, b) => a * b; // Parameters inferred from MathOp
// Optional and default parameters
function createUser(name: string, age: number = 25, email?: string) {
return { name, age, email };
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((a, b) => a + b, 0);
}
// Overload signatures
function parse(input: string): number;
function parse(input: number): string;
function parse(input: string | number): string | number {
if (typeof input === "string") return parseInt(input, 10);
return String(input);
}
Annotate return types on exported functions and public API boundaries. This catches accidental return type changes — if you refactor the implementation and accidentally change the return shape, the explicit annotation catches it at the function, not at every call site scattered across the codebase. Inside private implementation details, let inference handle it.
The any, unknown, and never Types
These three types are the escape hatches and endpoints of the type system:
// any — disables type checking entirely. Avoid.
let dangerous: any = "hello";
dangerous.foo.bar.baz(); // No error — any disables all checks
// unknown — the type-safe alternative to any
let safe: unknown = "hello";
safe.toUpperCase(); // ERROR — can't use unknown without narrowing
if (typeof safe === "string") {
safe.toUpperCase(); // OK — narrowed to string
}
// never — represents values that never occur
function throwError(msg: string): never {
throw new Error(msg); // Function never returns
}
// never in exhaustive checks
type Shape = "circle" | "square";
function area(shape: Shape): number {
switch (shape) {
case "circle": return Math.PI * 10;
case "square": return 100;
default:
const _exhaustive: never = shape; // ERROR if a case is missing
return _exhaustive;
}
}
any is contagious. If you assign an any to a typed variable, the typed variable keeps its type. But if you pass any into a generic function, the result is often any too. A single any in a chain of transformations can silently disable type checking for the entire pipeline. Use unknown instead and narrow explicitly.
Type Inference — When to Annotate, When to Trust
TypeScript's inference handles most cases. Here's the decision framework:
// DO annotate: function parameters (required — TS can't infer these)
function processUser(user: User) { /* ... */ }
// DO annotate: exported function return types (catches accidental changes)
export function getConfig(): AppConfig { /* ... */ }
// DON'T annotate: local variables with initializers (inference is exact)
const count = 0; // number — annotation adds noise
const users = getUsers(); // User[] — inferred from return type
// DON'T annotate: arrow function parameters in callbacks (contextual typing)
const doubled = [1, 2, 3].map(n => n * 2); // n is inferred as number
// DO annotate: empty initializations
const items: string[] = []; // Without annotation, inferred as never[]
Production Scenario: API Response Typing
// The wrong way — any everywhere
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
const data = await res.json(); // Returns any!
return data; // Return type: any — all type safety lost
}
// The right way — type the boundary
interface ApiUser {
id: string;
name: string;
email: string;
role: "admin" | "user" | "viewer";
createdAt: string;
}
async function fetchUser(id: string): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
const data: unknown = await res.json();
return data as ApiUser; // Explicit assertion at the boundary
}
// Even better — runtime validation with zod
import { z } from "zod";
const ApiUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "viewer"]),
createdAt: z.string().datetime(),
});
type ApiUser = z.infer<typeof ApiUserSchema>;
async function fetchUser(id: string): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
return ApiUserSchema.parse(await res.json()); // Runtime + compile-time safety
}
-
Wrong: Annotating every local variable: const x: number = 5 Right: Let inference work: const x = 5
-
Wrong: Using any for API responses from fetch/JSON.parse Right: Type the boundary with an interface or use runtime validation (zod)
-
Wrong: Forgetting to annotate empty arrays: const items = [] Right: Always annotate empty arrays: const items: string[] = []
-
Wrong: Using number[] when the array won't be mutated Right: Use readonly number[] in function parameters that don't mutate
- 1Annotate function parameters (required) and exported return types (recommended) — let inference handle the rest
- 2const infers literal types, let infers widened types — this distinction matters for discriminated unions
- 3Use unknown instead of any — it forces explicit narrowing before use
- 4Always annotate empty arrays and empty objects — inference can't determine the intended type from nothing
- 5Type your API boundaries explicitly — fetch().json() returns any and silently breaks type safety downstream