Skip to content

Basic Types

The vocabulary of the type system: what you can write on the right-hand side of a colon, and what TypeScript writes there for you when you don’t.

The lowercase names are the ones you want. There is one per JavaScript primitive:

let name: string = 'Ada';
let age: number = 36; // all numbers: integers, floats, Infinity, NaN
let big: bigint = 9007199254740993n;
let active: boolean = true;
let nothing: null = null;
let missing: undefined = undefined;
let key: symbol = Symbol('id');

Most of these annotations are redundant — see inference below. Write const name = 'Ada' and TypeScript already knows.

With strictNullChecks on (part of strict, and you should have it on), null and undefined are not members of every type. This is the single most valuable check TypeScript performs.

let s: string = null; // error: Type 'null' is not assignable to type 'string'
let t: string | null = null; // fine — you said so explicitly
function len(x: string | null): number {
return x.length; // error: 'x' is possibly 'null'
}

You fix that by narrowing:

function len(x: string | null): number {
if (x === null) return 0;
return x.length; // x is `string` here
}

Two equivalent syntaxes:

const scores: number[] = [1, 2, 3];
const names: Array<string> = ['a', 'b'];

T[] is conventional; Array<T> is occasionally clearer for complex element types, e.g. Array<{ id: number; tags: string[] }> versus { id: number; tags: string[] }[].

Arrays of unions need parentheses — the two spellings mean different things:

let a: (string | number)[]; // array whose elements are string or number
let b: string | number[]; // a string, OR an array of numbers

Read-only arrays reject mutation at compile time:

const frozen: readonly number[] = [1, 2, 3];
frozen.push(4); // error: Property 'push' does not exist on type 'readonly number[]'
const doubled = frozen.map((n) => n * 2); // fine — map doesn't mutate

readonly T[] and ReadonlyArray<T> are the same type. Note readonly is shallow: the elements themselves are still mutable if they’re objects.

A tuple is an array with a fixed length and a type per position.

type Point = [number, number];
const origin: Point = [0, 0];
const [x, y] = origin; // both number

Labels are allowed and show up in editor tooltips — purely cosmetic, but worth adding:

type Rgb = [red: number, green: number, blue: number];

Optional and rest elements:

type Range = [start: number, end: number, step?: number];
type NonEmpty = [string, ...string[]]; // at least one element
type Args = [name: string, ...flags: boolean[]];

Tuples are what makes React’s useState work:

function useToggle(initial: boolean): [boolean, () => void] {
// ...
return [initial, () => {}];
}
const [isOpen, toggle] = useToggle(false); // isOpen: boolean, toggle: () => void

enum is one of the few TypeScript features that emits runtime code.

enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
const d: Direction = Direction.Up;

That compiles to a real object, and numeric enums get a reverse mapping:

var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up";
Direction[Direction["Down"] = 1] = "Down";
// ...
})(Direction || (Direction = {}));

So Direction[0] === 'Up' at runtime. String enums do not get reverse mappings:

enum Status {
Active = 'ACTIVE',
Banned = 'BANNED',
}
Status.Active; // => 'ACTIVE'

const enum has no runtime representation; every usage is inlined.

const enum Level { Low = 1, High = 2 }
const l = Level.High; // emits: const l = 2 /* Level.High */

For the common case of “one of these string values”, a union of string literals is usually the better tool:

type Status = 'active' | 'banned' | 'pending';
function setStatus(s: Status) {}
setStatus('active'); // fine
setStatus('deleted'); // error, with a helpful suggestion

Compared to enums, unions:

  • Emit nothing — pure type-level, fully erasable.
  • Accept plain string literals, so they interoperate with JSON, APIs, and untyped callers.
  • Work with template literal types and other type-level machinery.

Enums win when you need a runtime object to iterate over or map through. If you want both, use an object with as const:

const Status = {
Active: 'active',
Banned: 'banned',
} as const;
type Status = (typeof Status)[keyof typeof Status]; // 'active' | 'banned'
Object.values(Status); // runtime iteration works

These four confuse people because they sound similar and behave nothing alike.

Type Meaning Can you assign to it? Can you use the value?
any Checking disabled Anything Anything — no checks at all
unknown Some value, identity unproven Anything Nothing until you narrow
never No value is possible Nothing N/A
void Return value should be ignored undefined (and null without strict) No

any turns the checker off for that value. It is assignable to and from everything, and it propagates silently.

const data: any = JSON.parse('{"a":1}');
data.b.c.d; // no error
data(); // no error
const n: number = data; // no error — the infection spreads

any is sometimes necessary, but every any is a hole in the type system. Enable noImplicitAny (part of strict) so the compiler never gives you one by accident.

unknown accepts any value but lets you do nothing with it until you prove what it is.

const data: unknown = JSON.parse('{"a":1}');
data.a; // error: 'data' is of type 'unknown'
const n: number = data; // error
if (typeof data === 'object' && data !== null && 'a' in data) {
console.log(data.a); // now allowed
}

This is the correct type for anything crossing a trust boundary: JSON.parse results, catch variables, values from postMessage, third-party callbacks.

never is the type with no values. It arises in three places:

// 1. A function that never returns normally.
function fail(msg: string): never {
throw new Error(msg);
}
// 2. An impossible narrowing.
function f(x: string) {
if (typeof x === 'number') {
x; // never — a string can't be a number
}
}
// 3. Filtering a union down to nothing.
type Empty = Exclude<'a' | 'b', 'a' | 'b'>; // never

Its practical use is exhaustiveness checking — see unions and narrowing. never is assignable to every type (the empty set is a subset of everything), and nothing except never is assignable to it.

void is the return type of a function that returns nothing useful. It is not the same as undefined:

function log(msg: string): void {
console.log(msg);
}

The important quirk: a function returning something is assignable to a position expecting a void-returning function. This is deliberate and makes callbacks ergonomic.

type Handler = () => void;
const h: Handler = () => 42; // allowed
const r = h(); // typed void — the value is there at runtime but you can't use it
// The reason this matters:
const nums: number[] = [];
[1, 2, 3].forEach((n) => nums.push(n)); // push returns number; forEach wants void. Fine.

A literal type is a type containing exactly one value.

let a: 'hello' = 'hello';
a = 'world'; // error: Type '"world"' is not assignable to type '"hello"'

You rarely write those directly. What matters is that const produces them automatically:

const greeting = 'hello'; // type: 'hello' (literal — it can never change)
let mutable = 'hello'; // type: string (widened — it might change)

This is literal widening. let and mutable object properties widen literals to their base type, because the value could be reassigned. const doesn’t, because it can’t.

The consequence bites when you build an object and pass it somewhere:

type Method = 'GET' | 'POST';
function request(url: string, method: Method) {}
const opts = { url: '/api', method: 'GET' }; // method inferred as string
request(opts.url, opts.method);
// error: Argument of type 'string' is not assignable to parameter of type 'Method'

Three fixes, in rough order of preference:

// 1. Annotate the object.
const opts: { url: string; method: Method } = { url: '/api', method: 'GET' };
// 2. Freeze the literals with `as const`.
const opts = { url: '/api', method: 'GET' } as const; // method: 'GET'
// 3. Assert just the one property.
const opts = { url: '/api', method: 'GET' as Method };

as const deserves emphasis. It marks an expression as deeply immutable and stops all widening:

const config = {
retries: 3,
mode: 'strict',
tags: ['a', 'b'],
} as const;
// {
// readonly retries: 3;
// readonly mode: 'strict';
// readonly tags: readonly ['a', 'b'];
// }

TypeScript infers types from initialisers, return expressions, default values, and the expected type of the surrounding context. Most code needs no annotations.

const n = 42; // 42 (const) — widens to number when used as a value
let m = 42; // number
const list = [1, 2, 3]; // number[]
const mixed = [1, 'a']; // (string | number)[]
const user = { id: 1, name: 'x' }; // { id: number; name: string }
function double(n: number) { // return type inferred: number
return n * 2;
}

Contextual typing works in the other direction — the expected type flows into an expression:

const nums = [1, 2, 3];
nums.forEach((n) => n.toFixed(2)); // `n` is number; no annotation needed
window.addEventListener('click', (e) => {
e.clientX; // `e` is MouseEvent, inferred from the event name
});
  • Always annotate function parameters. There is nothing to infer them from. Without noImplicitAny, they’d silently be any.
  • Annotate exported/public function return types if you want a stable contract and clearer errors. Inside a module, inferred returns are fine and reduce churn.
  • Annotate empty containers. const items = [] is any[] (or evolving any[] under strict); write const items: string[] = [].
  • Annotate when you want the checker to verify a value, not describe it:
// The annotation checks the literal against Config.
const config: Config = { /* missing fields are errors here */ };

Both name a type. For plain object shapes they are nearly interchangeable:

interface User {
id: number;
name: string;
}
type User2 = {
id: number;
name: string;
};

The differences, in one line each:

  • interface can be reopened — declare it twice and the members merge. type cannot.
  • type can name anything: unions, tuples, primitives, mapped and conditional types. interface only describes object shapes (including callable and constructable ones).
  • interface uses extends; type uses & (intersection).

Full treatment in functions and objects.

  • Use lowercase primitive names; never String/Number/Boolean.
  • strictNullChecks keeps null and undefined out of other types — keep it on.
  • T[] for arrays, fixed-length [A, B] for tuples; array literals need as const or an annotation to become tuples.
  • Prefer string-literal unions over enum; use an as const object when you also need runtime values.
  • any disables checking, unknown demands proof, never means impossible, void means “ignore the return value”.
  • const produces literal types; let widens. as const freezes an entire expression.
  • Annotate boundaries; let inference handle the rest.