Skip to content
TR

Phase 1 · Foundations · Lesson 1.2

Beginner

Primitives, arrays, tuples and the special types

The building blocks of every TypeScript type: the seven primitives, arrays and tuples, any vs unknown vs never vs void, object vs {} vs Object, and why `as` is a loaded gun.

20 min

Every type you will ever write is built from a small set of pieces. Most bugs in interview questions hide in the odd ones: the difference between any and unknown, why a tuple still lets you push, why {} accepts a number. Get these right and the rest of the type system reads like plain English.

The primitives

JavaScript has seven primitive values, and TypeScript has a type for each. The type names are lowercase.

const title: string = "TypeScript";
const count: number = 42;          // integers and floats: all one type
const done: boolean = false;
const huge: bigint = 9_007_199_254_740_993n; // needs target ES2020+
const id: symbol = Symbol("id");
const nothing: null = null;
const notSet: undefined = undefined;

Three details interviewers like:

  • There is no int or float. number is a 64-bit float, like JavaScript. For integers beyond 2^53 - 1, use bigint.
  • number and bigint don't mix. 1n + 1 is a type error, just as it's a runtime TypeError.
  • Lowercase, not uppercase. String, Number and Boolean are the wrapper object types. You almost never want them.
const big = 10n;
 
// @ts-expect-error -- Operator '+' cannot be applied to types 'bigint' and 'number'.
const mixed = big + 1;
 
const fine = big + 1n;
//    ^? const fine: bigint

A special symbol: unique symbol

A const declared with Symbol() gets its own type, unique symbol, so TypeScript can tell two symbols apart. A let widens to plain symbol.

const key = Symbol("key");
//    ^? const key: typeof key   (a unique symbol)
 
let other = Symbol("other");
//  ^? let other: symbol

null and undefined are their own types

With strictNullChecks (part of strict), null and undefined are not part of string or any other type. If a value can be missing, say so with a union:

let nickname: string | null = null;
nickname = "Ada";
 
// @ts-expect-error -- Type 'undefined' is not assignable to type 'string | null'.
nickname = undefined;

Arrays: T[] and Array<T>

Two spellings, one type. string[] and Array<string> are identical; pick one and be consistent. Most codebases use T[] for simple types.

const names: string[] = ["Ada", "Linus"];
const ages: Array<number> = [36, 54];
 
const mixed = [1, "two", 3];
//    ^? const mixed: (string | number)[]

Note the parentheses in (string | number)[]. Without them, string | number[] means "a string, or an array of numbers". A common typo.

Readonly arrays

readonly string[] (or ReadonlyArray<string>) removes every mutating method: push, pop, splice, sort, index assignment. It's the right type for a parameter you only read.

function first(items: readonly string[]) {
  // @ts-expect-error -- Property 'push' does not exist on type 'readonly string[]'.
  items.push("x");
  return items[0];
}
 
const list: string[] = ["a", "b"];
first(list); // fine: a mutable array can be passed where a readonly one is expected
 
const frozen: readonly string[] = ["a"];
// @ts-expect-error -- The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.
const copy: string[] = frozen;

The direction matters: mutable → readonly is fine, readonly → mutable is not, because the receiver could then mutate something you promised not to.

Tuples: arrays with a fixed shape

A tuple is an array where each position has its own type and the length is known.

const point: [number, number] = [10, 20];
const entry: [string, number] = ["age", 36];
 
const [key, value] = entry;
//          ^? const value: number
 
// @ts-expect-error -- Type 'string' is not assignable to type 'number'.
const wrong: [string, number] = ["age", "36"];

Optional, rest and labelled elements

// Optional element: must come after required ones; length is 1 | 2
type Range = [start: number, end?: number];
const open: Range = [5];
 
// Rest element: a string followed by any number of numbers
type Row = [label: string, ...values: number[]];
const row: Row = ["scores", 90, 85, 77];
 
// Rest can also sit at the start or in the middle
type Path = [...segments: string[], file: string];
const p: Path = ["src", "lib", "index.ts"];

The labels (start, end) are documentation only. They show up in editor hints and when a tuple is used as a function's parameter list, but they don't change the type: [start: number] and [number] are the same type.

Tuple vs array inference

TypeScript never infers a tuple from an array literal on its own. You get an array unless you ask:

const a = [1, "a"];
//    ^? const a: (string | number)[]
 
const b: [number, string] = [1, "a"]; // annotate
 
const c = [1, "a"] as const;
//    ^? const c: readonly [1, "a"]

as const gives you a readonly tuple of literal types. It's how you say "this exact value, frozen".

The tuple push gotcha

A plain tuple is still an array underneath, and TypeScript lets you call push on it:

const pair: [string, number] = ["a", 1];
pair.push(2); // compiles! length is now 3 at runtime, the type still says 2
 
const safePair: readonly [string, number] = ["a", 1];
// @ts-expect-error -- Property 'push' does not exist on type 'readonly [string, number]'.
safePair.push(2);

If a tuple is meant to stay fixed, make it readonly.

Quick check

What is the type of t?

const t = ["x", 1];

The special types: any, unknown, never, void

These four come up in almost every TypeScript interview.

any: the off switch

any turns type-checking off for that value. Anything can be assigned to it, and it can be assigned to anything, and every operation is allowed.

let loose: any = "hello";
loose.toFixed(2);        // compiles, crashes at runtime
const n: number = loose; // compiles: any flows into everything

any is contagious: loose.foo.bar is also any, and so on. It's an escape hatch for migration, not a type.

unknown: the safe "anything"

unknown also accepts any value, but you can't do anything with it until you narrow it.

function describe(value: unknown): string {
  // @ts-expect-error -- 'value' is of type 'unknown'.
  value.toUpperCase();
 
  if (typeof value === "string") {
    return value.toUpperCase(); // narrowed to string
  }
  return String(value);
}
 
const u: unknown = 1;
// @ts-expect-error -- Type 'unknown' is not assignable to type 'number'.
const num: number = u;

Use unknown for values from outside your program: JSON.parse results, catch variables (they are unknown under strict), anything from the network.

never: the empty type

never is the type with no values. Nothing is assignable to it (except never itself), and it's assignable to everything. You meet it in three places:

  1. A function that never returns (it always throws or loops forever).
  2. A variable in a branch that can't happen, after every case is ruled out.
  3. The result of impossible type operations, like string & number.
type Shape = "circle" | "square";
 
function area(shape: Shape): number {
  switch (shape) {
    case "circle": return 3.14;
    case "square": return 1;
    default: {
      const unreachable: never = shape; // compiles only if every case is handled
      return unreachable;
    }
  }
}

That default branch is the exhaustiveness check: add "triangle" to Shape and the assignment to never fails, pointing you at the switch you forgot.

void: "the return value doesn't matter"

void is the return type of a function that doesn't return anything useful. At runtime such a function returns undefined.

function log(message: string): void {
  console.log(message);
}
 
const result = log("hi");
//    ^? const result: void

void is not the same as undefined: it means "don't use this". The functions lesson shows why that difference matters for callbacks.

Quick check

Which line is a type error?

let a: any = 1;
let u: unknown = 1;
const s1: string = a;
const s2: string = u;

object vs {} vs Object

Three similar spellings, three different meanings:

TypeAcceptsRejects
object (lowercase)any non-primitive: objects, arrays, functionsstring, number, null, ...
{} (empty object type)everything except null and undefined, including 42 and "hi"null, undefined
Object (uppercase)almost the same as {}null, undefined
const o1: object = { a: 1 };
// @ts-expect-error -- Type 'number' is not assignable to type 'object'.
const o2: object = 42;
 
const e1: {} = 42;      // surprising but true: {} means "any non-nullish value"
const e2: {} = "hello";
// @ts-expect-error -- Type 'null' is not assignable to type '{}'.
const e3: {} = null;

The {} surprise is a favourite: an empty object type describes "something with at least no properties", and every non-nullish value qualifies. If you mean "any object", write object. If you mean "any value", write unknown. Object differs from {} only in that it checks members against Object.prototype (so a toString returning a number is rejected); linters ban both for good reason.

A first look at literal types

A literal type is a single exact value used as a type. On its own it's rarely useful; in a union it becomes an enum-like set of allowed values.

type Direction = "up" | "down" | "left" | "right";
 
function move(dir: Direction) {}
 
move("up");
// @ts-expect-error -- Argument of type '"forward"' is not assignable to parameter of type 'Direction'.
move("forward");
 
let status = "idle";     // let widens: string
//  ^? let status: string
const mode = "dark";     // const keeps the literal
//    ^? const mode: "dark"

Number and boolean literals work the same way: type Dice = 1 | 2 | 3 | 4 | 5 | 6, and boolean is literally true | false. Unions and narrowing get a full lesson later.

Type assertions: as and why it's dangerous

value as T tells the compiler "trust me, this is a T". It changes nothing at runtime. No conversion, no check.

interface User {
  name: string;
  email: string;
}
 
const user = {} as User; // compiles: you've lied to the compiler
user.email.toLowerCase(); // compiles, crashes: email is undefined

TypeScript does stop the most absurd assertions: the two types must overlap (one must be assignable to the other).

// @ts-expect-error -- Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other.
const n = "42" as number;
 
const forced = "42" as unknown as number; // the "double assertion" escape hatch
//    ^? const forced: number

A double assertion through unknown is a code-review red flag: it silences the only safety check assertions have.

Safer alternatives, in order of preference:

  • Annotate instead: const user: User = {...} checks the value really is a User.
  • Narrow with a runtime check (typeof, in, a type guard).
  • Use satisfies (later lesson) to check a value against a type without changing its inferred type.

The postfix ! is a close cousin: el! asserts "not null or undefined" and is equally unchecked.

Spot the error

This compiles, but it's unsafe in two places. Where, and what would you change?

interface Config {
  port: number;
  host: string;
}
 
function load(raw: string): Config {
  return JSON.parse(raw) as Config;
}
 
const coords: [number, number] = [1, 2];
coords.push(3);
Show the answer
  1. JSON.parse(raw) as Config: JSON.parse returns any, and the assertion trusts whatever arrived. If the JSON is {}, config.port is undefined at runtime. Treat parsed data as unknown and check it.
  2. coords.push(3): mutable tuples still have push. Make the tuple readonly.
interface Config {
  port: number;
  host: string;
}
 
function isConfig(value: unknown): value is Config {
  return (
    typeof value === "object" && value !== null &&
    "port" in value && typeof value.port === "number" &&
    "host" in value && typeof value.host === "string"
  );
}
 
function load(raw: string): Config {
  const data: unknown = JSON.parse(raw);
  if (!isConfig(data)) throw new Error("Invalid config");
  return data;
}
 
const coords: readonly [number, number] = [1, 2];
// @ts-expect-error -- Property 'push' does not exist on type 'readonly [number, number]'.
coords.push(3);

Try it

Play with the special types. Change unknown to any in parse and watch every error disappear, including the real bug.

function parse(json: string): unknown {
  return JSON.parse(json);
}
 
const data = parse('{"items": [1, 2, 3]}');
 
// @ts-expect-error -- 'data' is of type 'unknown'.
data.items.length;
 
if (typeof data === "object" && data !== null && "items" in data && Array.isArray(data.items)) {
  console.log(data.items.length); // narrowed: safe
}
 
const tuple = ["id", 7] as const;
//    ^? const tuple: readonly ["id", 7]
 
const empty: {} = 0; // allowed: {} is "not null or undefined"

▶ Try it in the TypeScript Playground

Recap

  • Seven primitives: string, number, boolean, bigint, symbol, null, undefined. Use lowercase names.
  • T[] and Array<T> are the same type. readonly T[] removes mutating methods; mutable → readonly is allowed, not the reverse.
  • Tuples fix each position's type; they support optional (?), rest (...) and labelled elements. Array literals infer as arrays; use an annotation or as const for a tuple.
  • Mutable tuples still allow push. Make fixed tuples readonly.
  • any turns checking off; unknown accepts anything but must be narrowed; never has no values; void means "ignore the return value".
  • object = non-primitive, {} = anything but null/undefined, Object ≈ {}.
  • as is an unchecked promise to the compiler. Prefer annotations, narrowing or satisfies.

Interview cards

1 / 7