Primitives, literals & unions
The seven primitives, how literal types are born and widened, and why a union is only as useful as the way you narrow it.
The primitives
const s: string = "text"
const n: number = 3.14 // one number type: no int/float split
const b: boolean = true
const big: bigint = 9007199254740993n
const sym: symbol = Symbol("key")
const u: undefined = undefined
const nul: null = null
console.log(typeof s, typeof n, typeof b, typeof big, typeof sym, typeof u, typeof nul)Note the last one: typeof null is "object", a JavaScript bug from 1995 that can never be fixed. TypeScript knows and handles it, but typeof x === "object" never excludes null for you.
โ ๏ธ Write string, number, boolean โ lowercase. The capitalised String, Number and Boolean are the wrapper object types, and almost nothing you want accepts them interchangeably.
number is a double
There is no integer type. Every number is an IEEE-754 double, with the consequences that implies.
console.log(0.1 + 0.2) // 0.30000000000000004 console.log(0.1 + 0.2 === 0.3) // false console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON) // the right comparison console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991 console.log(9007199254740993) // ...loses precision console.log(9007199254740993n) // bigint keeps it
๐งญ Scenario: An API returns 64-bit database ids. JavaScript rounds anything past 2^53, so two different records quietly become the same id. The fix is to transport ids as strings, or to parse them as bigint โ never as number.
Literal types
A literal type is a type with exactly one value. const produces one; let widens to the primitive.
const exact = "red" // type: "red" let widened = "red" // type: string const nums = [1, 2, 3] // number[] โ array contents always widen const frozen = [1, 2, 3] as const // readonly [1, 2, 3] console.log(exact, widened, nums.length, frozen.length)
let mode: "on" | "off" = "on" mode = "off" console.log(mode) mode = "maybe" // error! Type '"maybe"' is not assignable to type '"on" | "off"'.
Widening bites in objects
An object's properties are mutable, so their literal types widen โ which breaks assignment to a union type.
type Request = { url: string; method: "GET" | "POST" }
const config = { url: "/api", method: "GET" } // method: string, not "GET"
const req: Request = config // error! Type 'string' is not assignable to type '"GET" | "POST"'.Three fixes, in increasing order of preference:
type Request = { url: string; method: "GET" | "POST" }
const a = { url: "/api", method: "GET" as const } // pin one property
const b = { url: "/api", method: "GET" } as const // pin the whole object
const c: Request = { url: "/api", method: "GET" } // annotate the destination
console.log(a.method, b.method, c.method)๐ก Tip: satisfies gives you both โ the check and the narrow inferred type.
type Request = { url: string; method: "GET" | "POST" }
const req = { url: "/api", method: "GET" } satisfies Request
// Checked against Request, but req.method is "GET" โ not "GET" | "POST".
const pinned: "GET" = req.method
console.log(pinned, req.url)Unions
A union is "one of these". You cannot use a member until you know which one.
type Id = string | number
function describe(id: Id): string {
if (typeof id === "string") {
return `string of length ${id.length}`
}
return `number: ${id.toFixed(2)}`
}
console.log(describe("abc"))
console.log(describe(42))type Id = string | number
function bad(id: Id) {
return id.toFixed(2) // error! Property 'toFixed' does not exist on type 'string | number'.
}Only what every member has is available without narrowing:
type Id = string | number
function show(id: Id): string {
return id.toString() // both have toString
}
console.log(show("abc"), show(42))Unions of objects, and why a tag helps
type Loading = { status: "loading" }
type Success = { status: "success"; data: string[] }
type Failure = { status: "error"; message: string }
type State = Loading | Success | Failure
function render(state: State): string {
switch (state.status) {
case "loading":
return "โฆ"
case "success":
return `${state.data.length} results`
case "error":
return `Error: ${state.message}`
}
}
console.log(render({ status: "loading" }))
console.log(render({ status: "success", data: ["a", "b"] }))
console.log(render({ status: "error", message: "offline" }))That shared, literal-typed status property makes this a discriminated union โ the single most useful pattern in TypeScript. The compiler uses it to narrow, and to prove the switch is exhaustive.
Intersections
A & B means "both at once". Useful for composing, sharp edges when the parts conflict.
type HasId = { id: number }
type HasName = { name: string }
type Entity = HasId & HasName
const e: Entity = { id: 1, name: "Ada" }
console.log(e.id, e.name)
// Conflicting primitive members produce never โ a type with no values:
type Impossible = { v: string } & { v: number }
type V = Impossible["v"] // nevertype Impossible = { v: string } & { v: number }
const x: Impossible = { v: "anything" } // error! Type 'string' is not assignable to type 'never'.null, undefined and strictNullChecks
With strict on, null and undefined are separate types that must be handled.
function find(names: string[], q: string): string | undefined {
return names.find((n) => n === q)
}
const found = find(["Ada"], "Grace")
console.log(found?.toUpperCase() ?? "not found") // optional chaining + nullish coalescing
console.log(found?.length) // undefined, no throw
if (found !== undefined) {
console.log(found.toUpperCase()) // narrowed to string
}โ ๏ธ ?? falls back only on null/undefined; || falls back on every falsy value. count || 10 turns a legitimate 0 into 10. count ?? 10 does not.
const count = 0 console.log(count || 10) // 10 โ probably a bug console.log(count ?? 10) // 0 โ probably right
Reference: Everyday Types in the TypeScript Handbook.