Skip to main content
TypeScript · lesson 3 of 21
TypeScript beginner Lesson 3 of 21

Basic Types in TypeScript

TypeScript's built-in types — primitives, null, any vs unknown, void, never, arrays and tuples — each shown with real tsc errors and runtime output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

Primitive Types

string, number and boolean are the same three primitives JavaScript has. number covers integers, floats, hex and binary literals alike.

const userName: string = "Alice";
const age: number = 30;
const active: boolean = true;

const hex: number = 0xff;
const binary: number = 0b1010;
const float: number = 0.1 + 0.2;

const greeting: string = `${userName} is ${age}`;

console.log(greeting, active);
console.log(hex, binary, float);
console.log(typeof age, typeof float, Number.isInteger(age));
$ npx tsx primitives.ts
Alice is 30 true
255 10 0.30000000000000004
number number true

There’s one number type because JavaScript has one number representation: a 64-bit float. That’s why 0.1 + 0.2 prints 0.30000000000000004 and why typeof reports number for 30 and for the float alike. TypeScript types describe JavaScript values; they don’t change them.

The compiler rejects mixing them up:

const age: number = "30";
const active: boolean = 1;

function double(n: number): number {
  return n * 2;
}
console.log(double("21"));
$ npx tsc --noEmit --module nodenext primitives-bad.ts
primitives-bad.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.
primitives-bad.ts(2,7): error TS2322: Type 'number' is not assignable to type 'boolean'.
primitives-bad.ts(7,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

Plain JavaScript would run double("21") and quietly return 42 through string-to-number coercion — until the day it gets "21a" and returns NaN.

null and undefined

With strict mode on (strictNullChecks), null and undefined are not part of string or any other type. To allow them, say so with a union.

type User = { id: number; name: string };
const users: User[] = [{ id: 1, name: "Alice" }];

function findUser(id: number): User | null {
  return users.find((u) => u.id === id) ?? null;
}

const found = findUser(1);
const missing = findUser(2);

console.log(found?.name, missing?.name);
console.log(missing.name);
$ npx tsc --noEmit --module nodenext nulls.ts
nulls.ts(12,13): error TS18047: 'missing' is possibly 'null'.
$ npx tsx nulls.ts 2>&1 | head -6
Alice undefined
/root/ts/basic-types/nulls.ts:12
console.log(missing.name);
                    ^

TypeError: Cannot read properties of null (reading 'name')

The return type User | null makes absence part of the contract, so missing.name is rejected and ?. is the fix. tsx doesn’t type-check, so running anyway reaches the exact crash the compiler predicted. The | head -6 only trims Node’s stack trace.

any versus unknown

any turns the checker off for a value. unknown accepts the same values but won’t let you use one until you’ve checked what it is.

const loose: any = JSON.parse('{"count": 3}');
const strict: unknown = JSON.parse('{"count": 3}');

console.log(loose.count.toFixed(2));
console.log(loose.total.toFixed(2));

console.log(strict.count);
$ npx tsc --noEmit --module nodenext any-unknown.ts
any-unknown.ts(7,13): error TS18046: 'strict' is of type 'unknown'.

Only the unknown line is an error. loose.total.toFixed(2) compiles cleanly even though there is no total — any spreads, so every property of an any is also any. Delete the last line and run it:

const loose: any = JSON.parse('{"count": 3}');

console.log(loose.count.toFixed(2));
console.log(loose.total.toFixed(2));
$ npx tsc --noEmit --module nodenext any-crash.ts
$ npx tsx any-crash.ts 2>&1 | head -6
3.00
/root/ts/basic-types/any-crash.ts:4
console.log(loose.total.toFixed(2));
                        ^

TypeError: Cannot read properties of undefined (reading 'toFixed')

A clean type check and a crash. That’s the whole problem with any. With unknown, you narrow first:

function describe(input: unknown): string {
  if (typeof input === "string") return `string of length ${input.length}`;
  if (typeof input === "number") return `number ${input.toFixed(1)}`;
  if (Array.isArray(input)) return `array of ${input.length}`;
  if (input === null) return "null";
  return `something else: ${typeof input}`;
}

for (const value of ["hi", 3, [1, 2], null, { a: 1 }]) {
  console.log(describe(value));
}
$ npx tsc --noEmit --module nodenext narrow.ts
$ npx tsx narrow.ts
string of length 2
number 3.0
array of 2
null
something else: object

Inside each if, the compiler narrows input to the checked type, so input.length and input.toFixed are allowed there and nowhere else. The null check matters because typeof null is "object" — a JavaScript quirk the narrowing rules model exactly.

void and never

void is the return type of a function whose result you shouldn’t use. never is the type of a value that can’t exist — for example, the return value of a function that always throws.

function log(message: string): void {
  console.log(`[log] ${message}`);
}

function fail(message: string): never {
  throw new Error(message);
}

function parsePort(raw: string): number {
  const port = Number(raw);
  if (Number.isInteger(port) && port > 0) return port;
  fail(`bad port: ${raw}`);
}

log(`port ${parsePort("8080")}`);
const result = log("done");
console.log(result);
try {
  parsePort("http");
} catch (e) {
  console.log((e as Error).message);
}
$ npx tsc --noEmit --module nodenext void-never.ts
$ npx tsx void-never.ts
[log] port 8080
[log] done
undefined
bad port: http

parsePort promises a number but its last line returns nothing — and it still compiles, because fail returns never, so the compiler knows the end of the function is unreachable. Change fail’s return type to void and you get TS2366, “Function lacks ending return statement and return type does not include ‘undefined’”. The void function really returns undefined, as console.log(result) shows.

Exhaustive Checks with never

The most useful place for never is the default of a switch over a union. If every case is handled, the value left over has type never.

type Shape = "circle" | "square" | "triangle" | "hexagon";

function sides(shape: Shape): number {
  switch (shape) {
    case "circle":
      return 0;
    case "square":
      return 4;
    case "triangle":
      return 3;
    default: {
      const unhandled: never = shape;
      throw new Error(`Unhandled shape: ${unhandled}`);
    }
  }
}

console.log(sides("square"));
$ npx tsc --noEmit --module nodenext exhaustive.ts
exhaustive.ts(12,13): error TS2322: Type '"hexagon"' is not assignable to type 'never'.

"hexagon" was added to the union but not to the switch, so it’s the one value that reaches default, and it can’t be assigned to never. The error points at the exact line, and the message names the missing case. Add case "hexagon": return 6; and the error disappears.

Arrays and Tuples

An array holds any number of one type. A tuple is a fixed-length array where each position has its own type.

const scores: number[] = [95, 87];
const names: Array<string> = ["Alice", "Bob"];

type Point = [x: number, y: number];
const origin: Point = [0, 0];
const entry: [string, number] = ["age", 30];

function minMax(values: number[]): [min: number, max: number] {
  return [Math.min(...values), Math.max(...values)];
}

const [lo, hi] = minMax(scores);
console.log(names, origin, entry, lo, hi);
console.log(Array.isArray(origin), origin.length);

const bad: Point = [1, 2, 3];
entry[0] = 42;
$ npx tsc --noEmit --module nodenext tuples.ts
tuples.ts(16,7): error TS2322: Type '[number, number, number]' is not assignable to type 'Point'.
  Source has 3 element(s) but target allows only 2.
tuples.ts(17,1): error TS2322: Type 'number' is not assignable to type 'string'.

number[] and Array<string> are the same thing spelled two ways. Tuples are checked for both length and per-position type — which is what makes const [lo, hi] = minMax(...) return two numbers rather than “some numbers”. At runtime a tuple is an ordinary array: remove the last two lines and Array.isArray(origin) prints true.

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

function minMax(values: number[]): [min: number, max: number] {
  return [Math.min(...values), Math.max(...values)];
}

const [lo, hi] = minMax([95, 87, 91]);
console.log(origin, lo, hi, Array.isArray(origin));
$ npx tsx tuples-ok.ts
[ 0, 0 ] 87 95 true

object, symbol and bigint

object means “any non-primitive”. symbol is a unique key. bigint is an integer that never loses precision.

function keys(obj: object): string[] {
  return Object.keys(obj);
}

const id: symbol = Symbol("id");
const record = { [id]: 1, name: "Alice" };

const safe = Number.MAX_SAFE_INTEGER;
const big: bigint = 9007199254740991n;

console.log(keys(record), record[id]);
console.log(safe + 2, big + 2n);
console.log(keys(42));
$ npx tsc --noEmit --module nodenext rare.ts
rare.ts(13,18): error TS2345: Argument of type 'number' is not assignable to parameter of type 'object'.

42 is a primitive, so it isn’t an object. With the last line removed:

function keys(obj: object): string[] {
  return Object.keys(obj);
}

const id: symbol = Symbol("id");
const record = { [id]: 1, name: "Alice" };

const safe = Number.MAX_SAFE_INTEGER;
const big: bigint = 9007199254740991n;

console.log(keys(record), record[id]);
console.log(safe + 2, big + 2n);
$ npx tsx rare-ok.ts
[ 'name' ] 1
9007199254740992 9007199254740993n

Object.keys skips symbol keys, so id is a key that ordinary iteration never sees. And safe + 2 printed 9007199254740992 — off by one, because a float can’t represent 2^53 + 1. The bigint version is exact. bigint needs target ES2020 or later; TypeScript 6’s default target is recent enough.

Summary

TypeHoldsUse case
string, number, booleanJavaScript primitivesAlmost everything
null, undefinedAbsenceAdd with a union: User | null
anyAnything, uncheckedMigration; avoid otherwise
unknownAnything, must narrowJSON, user input, catch values
voidundefined from a functionSide-effect-only functions
neverNothingAlways-throwing functions, exhaustive checks
T[], [A, B]Arrays, tuplesLists; fixed multi-value returns

Practice

1. Make describe in narrow.ts also handle boolean values, printing boolean true.

Solution
function describe(input: unknown): string {
  if (typeof input === "string") return `string of length ${input.length}`;
  if (typeof input === "boolean") return `boolean ${input}`;
  if (input === null) return "null";
  return `something else: ${typeof input}`;
}

console.log(describe(true), "|", describe("hey"), "|", describe(undefined));
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
boolean true | string of length 3 | something else: undefined

typeof narrows unknown to boolean just like it does for string and number.

2. Add "hexagon" to sides in exhaustive.ts so the file compiles, then add a fifth shape "pentagon" to the union without a case. Which line does tsc point at?

Solution
type Shape = "circle" | "square" | "triangle" | "hexagon" | "pentagon";

function sides(shape: Shape): number {
  switch (shape) {
    case "circle": return 0;
    case "square": return 4;
    case "triangle": return 3;
    case "hexagon": return 6;
    default: {
      const unhandled: never = shape;
      throw new Error(`Unhandled shape: ${unhandled}`);
    }
  }
}
console.log(sides("hexagon"));
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(10,13): error TS2322: Type '"pentagon"' is not assignable to type 'never'.

The same never line, now naming "pentagon". The check scales to any number of cases without a code change — only the union changes.

3. Write parseAge(raw: unknown): number | null that accepts a non-negative number or a numeric string like "30", and returns null for anything else. Test it on 25, "30", "abc", -4 and null.

Solution
function parseAge(raw: unknown): number | null {
  if (typeof raw === "number") {
    return Number.isInteger(raw) && raw >= 0 ? raw : null;
  }
  if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
    return Number(raw);
  }
  return null;
}

for (const raw of [25, "30", "abc", -4, null, "12abc"]) {
  console.log(JSON.stringify(raw), "->", parseAge(raw));
}
$ npx tsc --noEmit --module nodenext practice-3.ts
$ npx tsx practice-3.ts
25 -> 25
"30" -> 30
"abc" -> null
-4 -> null
null -> null
"12abc" -> null

The regex matters: parseInt("12abc", 10) would return 12, silently accepting garbage. The types force you to handle each kind of input, but they can’t tell you which parsing function is too forgiving — that’s what the test values are for.

Frequently Asked Questions

What is the difference between any and unknown?
Both can hold any value, but unknown is safer. With any you can do anything to the value without checks. With unknown you must narrow the type first before using it, which forces you to handle edge cases.
When should I use never?
Use never for functions that never return (they throw or loop forever) and as the bottom of exhaustive checks. TypeScript infers never automatically in many cases.
Should I ever use any?
Avoid it when possible. any completely disables type checking for that value. Prefer unknown for values whose type you genuinely don't know, and use type guards to narrow from there.
Does TypeScript have separate integer and float types?
No. Like JavaScript, TypeScript has a single number type, a 64-bit float, which is exact for integers up to 2^53 - 1. For larger integers use bigint, written with an n suffix, which never loses precision but can't be mixed with number in arithmetic.
Do TypeScript types exist at runtime?
No. Types are erased when TypeScript compiles to JavaScript, so a tuple is an ordinary array and an unknown value is whatever JavaScript value was passed in. Runtime checks such as typeof, Array.isArray and instanceof are how you inspect a value while the program runs.