2.1 Primitive Types
TypeScript's type system starts by adding a static type layer on top of JavaScript's primitive values. Primitive types are the simplest units of data — they are not objects and have no methods. As of TypeScript 5.x, there are seven primitive types: string, number, boolean, null, undefined, symbol, and bigint. This chapter covers the characteristics of each type, how to write type annotations, safe handling of null/undefined, why wrapper objects must be avoided, and practical uses of bigint and symbol.
Type Annotations — Explicit vs Inferred
TypeScript assigns types in two ways.
Explicit annotations: Write : type directly after a variable or parameter name.
const username: string = "Alice";
const age: number = 30;
const isAdmin: boolean = false;
Type inference: When an initial value is present, the TypeScript compiler analyzes the value and determines the type automatically.
const username = "Alice"; // inferred: string
const age = 30; // inferred: number
const isAdmin = false; // inferred: boolean
When the initial value is clear, you can omit the annotation — adding one just introduces redundant noise. On the other hand, explicit annotations are safer for function parameters, variables that will be assigned later, and cases where the return type is ambiguous.
// Recommended: let inference handle it when the type is clear at initialization
const title = "TypeScript Guide";
// Recommended: be explicit when assigning later or when null is possible
let currentUser: string | null = null;
currentUser = "Bob";
// Recommended: always annotate function parameters explicitly
function greet(name: string): string {
return `Hello, ${name}`;
}
string
Strings can be expressed with single quotes ('), double quotes ("), or backticks (`). TypeScript treats all three forms as the same string type.
const firstName: string = "Alice";
const lastName: string = 'Wonderland';
const fullName: string = `${firstName} ${lastName}`; // template literal
String Methods and Type Safety
TypeScript raises a compile-time error if you call a number method on a string type variable.
const message = "hello world";
console.log(message.toUpperCase()); // "HELLO WORLD" — OK
console.log(message.toFixed(2)); // error: Property 'toFixed' does not exist on type 'string'
Literal Types
String literal types, which allow only specific string values rather than all strings, are also built on top of the primitive type.
type Direction = "north" | "south" | "east" | "west";
function move(dir: Direction): void {
console.log(`Moving ${dir}`);
}
move("north"); // OK
move("up"); // error: Argument of type '"up"' is not assignable to parameter of type 'Direction'
number
Like JavaScript, TypeScript's number is a single 64-bit floating-point value (IEEE 754) that represents both integers and decimals. Remember that there is no separate integer-only type.
const integer: number = 42;
const float: number = 3.14159;
const negative: number = -100;
// Various literal notations
const hex: number = 0xff; // 255 (hexadecimal)
const octal: number = 0o377; // 255 (octal)
const binary: number = 0b11111111; // 255 (binary)
const million: number = 1_000_000; // numeric separator (ES2021)
Special Values
The number type also includes Infinity, -Infinity, and NaN.
const inf: number = Infinity;
const notANumber: number = NaN;
// NaN check
function safeDivide(a: number, b: number): number {
if (b === 0) return NaN;
return a / b;
}
const result = safeDivide(10, 0);
if (Number.isNaN(result)) {
console.log("Division error: denominator is 0");
}
Safe Integer Range
Integers exceeding Number.MAX_SAFE_INTEGER (2^53 - 1 = 9,007,199,254,740,991) cannot be represented accurately with the number type. Use bigint for financial or cryptographic calculations.
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992 — wrong! same value
boolean
A simple type that holds only two values: true or false.
const isLoggedIn: boolean = true;
const hasPermission: boolean = false;
function checkAccess(isAdmin: boolean, isOwner: boolean): boolean {
return isAdmin || isOwner;
}
Note: The Difference Between truthy/falsy and boolean
In JavaScript's implicit coercion, 0, "", null, undefined, and NaN are falsy, but in TypeScript these values are not of type boolean.
const value: boolean = 0; // error: Type 'number' is not assignable to type 'boolean'
const value2: boolean = !!0; // OK — explicit conversion via double negation
const value3: boolean = Boolean(0); // OK
null and undefined
null and undefined are each independent types.
undefined: a state where no value has been assignednull: an intentional representation of "no value"
let u: undefined = undefined;
let n: null = null;
These two types are rarely used on their own. They shine when expressing "a value that may or may not exist" together with union types.
let userId: number | null = null; // user not yet logged in
userId = 42; // assign ID after login
strictNullChecks
When "strict": true or "strictNullChecks": true is set in tsconfig.json, null and undefined cannot be automatically assigned to other types. Without this setting, you can put null and undefined into any type, which is a major source of runtime errors.
// strictNullChecks: true environment
let name: string = "Alice";
name = null; // error: Type 'null' is not assignable to type 'string'
name = undefined; // error: Type 'undefined' is not assignable to type 'string'
// To allow null, you must use a union type explicitly
let nickname: string | null = null;
nickname = "Ally"; // OK
Optional Chaining
The ?. operator returns undefined without throwing an error when accessing a value that is null or undefined.
interface User {
id: number;
profile?: {
bio: string;
website?: string;
};
}
const user: User | null = null;
// Traditional approach — verbose
const bio = user !== null && user.profile !== undefined ? user.profile.bio : undefined;
// Optional chaining — concise
const bio2 = user?.profile?.bio;
const website = user?.profile?.website; // undefined (no error)
Nullish Coalescing (??)
The ?? operator returns the right-hand default value only when the left-hand side is null or undefined. Unlike the || operator, it preserves falsy-but-valid values like 0 or "".
const count: number | null = 0;
const result1 = count || 10; // 10 — wrong! 0 is a valid value but gets overwritten by the default
const result2 = count ?? 10; // 0 — correct! default only used when null/undefined
function getDisplayName(name: string | null | undefined): string {
return name ?? "Anonymous User";
}
console.log(getDisplayName(null)); // "Anonymous User"
console.log(getDisplayName(undefined)); // "Anonymous User"
console.log(getDisplayName("")); // "" — empty string treated as a valid name
console.log(getDisplayName("Alice")); // "Alice"
Non-null Assertion
Use the ! operator when the compiler suspects a null possibility but you are certain the value is never null. Avoid overusing it.
function processElement(id: string): void {
const el = document.getElementById(id);
// el is of type HTMLElement | null
// el!.textContent = "Hello"; // using ! — will throw at runtime if el is null
// Safer approach: verify with a type guard
if (el !== null) {
el.textContent = "Hello"; // inside this block, el is narrowed to HTMLElement
}
}
symbol
A unique and immutable value created with Symbol(). Even with the same description string, two symbols are never equal.
const sym1 = Symbol("id");
const sym2 = Symbol("id");
console.log(sym1 === sym2); // false — always unique
console.log(typeof sym1); // "symbol"
Using Symbols as Unique Object Keys
Using a symbol as an object property key creates a "hidden" property that cannot be accessed or overwritten from outside.
const ID = Symbol("userId");
const SECRET = Symbol("secret");
interface UserRecord {
[ID]: number;
name: string;
}
const record: UserRecord = {
[ID]: 101,
name: "Alice",
};
console.log(record[ID]); // 101
console.log(record.name); // "Alice"
// Object.keys(record) does not return [ID] — it is non-enumerable
unique symbol
Combining a const declaration with the unique symbol type guarantees uniqueness at the type level as well.
const TOKEN: unique symbol = Symbol("token");
type TokenType = typeof TOKEN;
// unique symbols are only type-compatible with those from the same declaration
const OTHER: unique symbol = Symbol("token");
// TOKEN === OTHER; // error: This condition will always return 'false' since the types have no overlap
Well-known Symbols
Special symbols that customize built-in JavaScript behavior.
class Range {
constructor(private start: number, private end: number) {}
// Support for...of loops
[Symbol.iterator](): Iterator<number> {
let current = this.start;
const end = this.end;
return {
next(): IteratorResult<number> {
if (current <= end) {
return { value: current++, done: false };
}
return { value: 0, done: true };
},
};
}
}
const range = new Range(1, 5);
for (const n of range) {
process.stdout.write(n + " "); // 1 2 3 4 5
}
bigint
bigint is the type for handling integers that exceed Number.MAX_SAFE_INTEGER with precision. You must set "target": "ES2020" or higher in tsconfig.json.
const maxSafe: bigint = BigInt(Number.MAX_SAFE_INTEGER); // 9007199254740991n
const bigger: bigint = 9007199254740992n; // using n suffix
const sum: bigint = maxSafe + 1n; // 9007199254740992n — accurate!
// number and bigint cannot be mixed directly
// const mixed = maxSafe + 1; // error: Operator '+' cannot be applied to types 'bigint' and 'number'
const converted = maxSafe + BigInt(1); // convert with BigInt() before operating
Financial Calculation in Practice
number is prone to floating-point errors, making it unsuitable for financial calculations. Raise amounts to the smallest unit (cents for USD, pence for GBP) and handle them as bigint.
// Manage amounts as cent-unit bigints
type Cents = bigint;
function addAmount(a: Cents, b: Cents): Cents {
return a + b;
}
function applyTax(amount: Cents, taxRateBps: bigint): Cents {
// taxRateBps: tax rate in basis points (1% = 100bps)
return (amount * taxRateBps) / 10000n;
}
const price: Cents = 1000n; // $10.00
const tax = applyTax(price, 1000n); // 10% tax = 100n ($1.00)
const total = addAmount(price, tax); // 1100n ($11.00)
console.log(`Total: ${total} cents`); // Total: 1100 cents
Cryptographic Operations
Public-key cryptography algorithms like RSA require arithmetic on hundreds of digits. Real cryptography libraries (e.g., node-forge) use bigint internally.
// Simple modular exponentiation example (use a library for real cryptography)
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) {
result = (result * base) % mod;
}
exp = exp / 2n;
base = (base * base) % mod;
}
return result;
}
// Public exponent e=65537, very small example modulus
const message = 42n;
const e = 65537n;
const n = 3233n; // p=61, q=53 (in practice, hundreds of bits)
const encrypted = modPow(message, e, n);
console.log(`Encrypted: ${encrypted}`);
Why You Must Never Use Wrapper Objects
JavaScript has wrapper objects String, Number, and Boolean that correspond to the string, number, and boolean primitive types. TypeScript also allows these capitalized types in annotations, but you should never use them.
Type Mismatch Problem
// Wrong code
const name1: String = "Alice"; // wrapper object type — avoid this
const name2: string = new String("Alice"); // error! String cannot be assigned to string
// Correct code
const name3: string = "Alice"; // primitive type — always use lowercase
string (primitive type) is assignable to String (object type), but String is not assignable to string. This asymmetry causes unexpected type errors.
Equality Comparison Failures
const a = new String("hello");
const b = new String("hello");
console.log(a === b); // false — different object references
console.log(a == b); // false
console.log(a === "hello"); // false — object and primitive are different
console.log(a.valueOf() === "hello"); // true — primitive value only extractable via valueOf()
Primitive types are compared by value, but wrapper objects are compared by reference. This can completely break conditional logic.
Memory and Performance
Wrapper objects are heap-allocated, while primitive values are handled on the stack or inline. Storing wrapper objects in large arrays greatly increases memory usage and GC pressure.
// Bad — array of objects
const badNames: String[] = [new String("Alice"), new String("Bob")];
// Good — array of primitives
const goodNames: string[] = ["Alice", "Bob"];
The ESLint rule @typescript-eslint/ban-types disallows the use of String, Number, Boolean, Object, and Symbol in type annotations by default.
Practical Example 1: User Profile Type
// User profile composed of primitive types
interface UserProfile {
id: number;
username: string;
email: string;
age: number | null; // optionally provided age
isVerified: boolean;
createdAt: string; // ISO 8601 date string
accountBalance: bigint; // balance (in cents, precision guaranteed)
sessionToken: symbol | null; // session token (uniqueness guaranteed)
}
function createUser(username: string, email: string): UserProfile {
return {
id: Math.floor(Math.random() * 1_000_000),
username,
email,
age: null,
isVerified: false,
createdAt: new Date().toISOString(),
accountBalance: 0n,
sessionToken: null,
};
}
function login(user: UserProfile): UserProfile {
return {
...user,
sessionToken: Symbol(`session-${user.id}`),
};
}
function getDisplayAge(user: UserProfile): string {
return user.age ?? "Age not provided";
}
// Usage
let user = createUser("alice", "alice@example.com");
user = login(user);
console.log(getDisplayAge(user)); // "Age not provided"
user = { ...user, age: 28 };
console.log(getDisplayAge(user)); // "28"
Practical Example 2: Money Calculator
type Currency = "USD" | "EUR" | "GBP";
interface Money {
amount: bigint; // smallest unit (USD: cents, EUR: euro-cents, GBP: pence)
currency: Currency;
}
function createMoney(amount: bigint, currency: Currency): Money {
if (amount < 0n) throw new Error("Amount cannot be negative");
return { amount, currency };
}
function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`Currency mismatch: ${a.currency} vs ${b.currency}`);
}
return { amount: a.amount + b.amount, currency: a.currency };
}
function formatMoney(money: Money): string {
const divisors: Record<Currency, bigint> = {
USD: 100n,
EUR: 100n,
GBP: 100n,
};
const symbols: Record<Currency, string> = {
USD: "$",
EUR: "€",
GBP: "£",
};
const divisor = divisors[money.currency];
const whole = money.amount / divisor;
const fraction = money.amount % divisor;
return `${symbols[money.currency]}${whole}.${fraction.toString().padStart(2, "0")}`;
}
// Usage
const price = createMoney(999n, "USD"); // $9.99
const shipping = createMoney(500n, "USD"); // $5.00
const total = addMoney(price, shipping);
console.log(formatMoney(total)); // $14.99
const eurPrice = createMoney(1299n, "EUR"); // €12.99
console.log(formatMoney(eurPrice)); // €12.99
Pro Tips
Tip 1: Chain optional chaining with nullish coalescing
interface Config {
db?: {
host?: string;
port?: number;
};
}
function getDbHost(config: Config): string {
return config?.db?.host ?? "localhost";
}
function getDbPort(config: Config): number {
return config?.db?.port ?? 5432;
}
Tip 2: Centralize null handling with type guard functions
function assertDefined<T>(value: T | null | undefined, label: string): T {
if (value === null || value === undefined) {
throw new Error(`${label} is not defined`);
}
return value;
}
// Usage
const userId = assertDefined(getStoredUserId(), "userId");
// userId is now narrowed to a type without null/undefined
Tip 3: Abstract bigint arithmetic utilities with type aliases
type Cents = bigint; // USD cents
type Pence = bigint; // GBP pence
// Distinguishing units with type aliases helps prevent mixing errors
// (For actual type-level branding, see Ch5 intersection types)
Tip 4: Use symbols as collision-free event keys across modules
// events.ts
export const USER_LOGGED_IN = Symbol("userLoggedIn");
export const USER_LOGGED_OUT = Symbol("userLoggedOut");
// auth.ts
import { USER_LOGGED_IN } from "./events";
const bus = new Map<symbol, Function[]>();
function emit(event: symbol, data: unknown): void {
bus.get(event)?.forEach((fn) => fn(data));
}
emit(USER_LOGGED_IN, { userId: 1 });
// Symbol keys eliminate string typo collisions
Tip 5: Always enable strictNullChecks
Simply setting "strict": true in tsconfig.json activates strictNullChecks. Without this setting, TypeScript cannot catch null errors, and Cannot read properties of null errors will occur frequently at runtime.
Summary Table
| Type | Example Values | Primary Use | Notes |
|---|---|---|---|
string | "hello", `Hi` | Text data | Do not use wrapper String |
number | 42, 3.14, NaN | General numeric operations | Use bigint above 2^53 |
boolean | true, false | Logical values | Distinguish from falsy values like 0/"" |
null | null | Intentional absence of value | Must enable strictNullChecks |
undefined | undefined | Unassigned state | Use ?? operator for defaults |
symbol | Symbol("key") | Unique keys, metaprogramming | Not serializable |
bigint | 9007199254740992n | Large integers, finance/crypto | Requires ES2020 target, cannot mix with number |
The next chapter covers four special types: any, unknown, never, and void. Unlike primitive types, these deal with the boundaries of the type system. Correctly using unknown and never in particular is central to writing safe TypeScript code.