TypeScript · lesson 3 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Basic 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
| Type | Holds | Use case |
|---|---|---|
string, number, boolean | JavaScript primitives | Almost everything |
null, undefined | Absence | Add with a union: User | null |
any | Anything, unchecked | Migration; avoid otherwise |
unknown | Anything, must narrow | JSON, user input, catch values |
void | undefined from a function | Side-effect-only functions |
never | Nothing | Always-throwing functions, exhaustive checks |
T[], [A, B] | Arrays, tuples | Lists; 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.