Types, Interfaces, Narrowing
Primitives, unions, interfaces, type aliases, narrowing.
TypeScript's job is to describe the shape of your data. Most of that description lives in two concepts: basic types you compose from, and type aliases (or interfaces) you build with. Let's walk the whole inventory and then narrow values down to the specific shape you actually have.
The primitive types
Every TS primitive maps to a JS primitive. Lowercase names. Always.
const name: string = "Ada";
const age: number = 36;
const isAdmin: boolean = true;
const huge: bigint = 9_007_199_254_740_993n;
const id: symbol = Symbol("id");
const nothing: null = null;
const empty: undefined = undefined;string, not String. The capitalized ones (String, Number, Boolean) refer to the rarely-used wrapper objects. Stick with lowercase unless you have a very specific reason not to.The escape hatches
any= "turn off type checking for this value." Use sparingly. Spreads like a virus.unknown= "I don't know what this is, and you have to narrow it before using it." The safe alternative toany.never= "this can't happen." The return type of a function that always throws, or the type of a variable in an unreachable branch.void= "this function returns nothing interesting." Distinct fromundefined.
Arrays and tuples
// Two equivalent ways to type an array
const names: string[] = ["Ada", "Grace"];
const ages: Array<number> = [36, 85];
// A tuple is a fixed-length array with a type per position
const point: [number, number] = [10, 20];
const labeled: [string, number] = ["score", 99];
// Tuples can have rest elements
const variadic: [string, ...number[]] = ["scores", 90, 80, 70];
// readonly tuples reject mutation
const origin: readonly [number, number] = [0, 0];
// origin[0] = 5; // Error: Cannot assign to '0' because it is read-onlyObject types
You can describe an object shape inline, or give it a name with a type alias or an interface.
// Inline
function greet(user: { name: string; age: number }) {
return "Hi " + user.name;
}
// Named with type alias
type User = {
name: string;
age: number;
};
// Named with interface
interface UserI {
name: string;
age: number;
}Optional and readonly properties
type Profile = {
readonly id: string; // can never be reassigned after creation
name: string;
bio?: string; // optional, type is string | undefined
};
const p: Profile = { id: "u_1", name: "Ada" };
// p.id = "u_2"; // Error: Cannot assign, id is readonly
// p.bio is fine to be missingbio?: string means the key may not exist on the object at all. bio: string | undefined means the key must exist but may hold undefined. Different things. Pick deliberately.Union and intersection types
A union (|) means "one of these." An intersection (&) means "all of these at once."
// Union: a value is one OR the other
type Status = "pending" | "done" | "error";
type Id = string | number;
// Intersection: combine all properties
type HasName = { name: string };
type HasAge = { age: number };
type Person = HasName & HasAge; // requires BOTH name and age
const p: Person = { name: "Ada", age: 36 };Literal types
A type can be a specific value, not just a category. This is how you get enum-like behavior without an enum.
type Direction = "up" | "down" | "left" | "right";
function move(d: Direction) { /* ... */ }
move("up"); // OK
move("upward"); // Error: not assignable to '"up" | "down" | "left" | "right"'type vs interface
99% of the time, they're interchangeable. Here's the difference that actually matters:
interfacecan be reopened. Declare the same name twice and TS merges them. Useful for extending third-party types.typecannot be reopened. It can model unions, intersections, tuples, and conditional types, things interfaces can't express directly.
// Interface declaration merging
interface Window {
myCustomFlag?: boolean; // adds a property to the built-in Window
}
// Type aliases cannot do this
type Foo = { a: number };
// type Foo = { b: string }; // Error: Duplicate identifier
// Only type can model a union
type Result = { ok: true; value: number } | { ok: false; error: string };type by default. Reach for interface when you specifically want declaration merging or when modeling a class-like contract you expect to extend.Narrowing: telling TS what you already know
A union type says "this could be one of several things." Before you can use it as a specific thing, you have to narrow it. TS understands several patterns:
typeof x === "string"for primitivesx instanceof Datefor class instances"name" in xfor checking a property exists- A discriminated union with a shared literal property
function describe(x: string | number) {
if (typeof x === "string") {
return x.toUpperCase(); // TS knows x is string here
}
return x.toFixed(2); // TS knows x is number here
}Discriminated unions: the killer pattern
Give every variant in a union a unique literal "tag." TS will use it to narrow with a single switch.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
case "rect": return s.w * s.h;
}
}Try it
Edit the playground below. Try changing the user shape, adding a new Shape variant, or breaking the narrowing on purpose to see what TS complains about.
What is the difference between `bio?: string` and `bio: string | undefined`?
Recap
- Primitives are lowercase:
string,number,boolean, and friends. - Arrays:
T[]orArray<T>. Tuples are fixed-length arrays with a type per index. ?= optional key.readonly= no reassignment. Different things.- Union (
|) = one of. Intersection (&) = all of. Literal types pin a value. - Use
typeby default.interfacefor declaration merging. - Narrowing turns a wide union into a specific shape inside a branch. Discriminated unions are the cleanest way.