Skip to main content
Advertisement

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 assigned
  • null: 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

TypeExample ValuesPrimary UseNotes
string"hello", `Hi`Text dataDo not use wrapper String
number42, 3.14, NaNGeneral numeric operationsUse bigint above 2^53
booleantrue, falseLogical valuesDistinguish from falsy values like 0/""
nullnullIntentional absence of valueMust enable strictNullChecks
undefinedundefinedUnassigned stateUse ?? operator for defaults
symbolSymbol("key")Unique keys, metaprogrammingNot serializable
bigint9007199254740992nLarge integers, finance/cryptoRequires 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.

Advertisement