TypeScript Interface vs Type, Structural Typing and Unions
TypeScript interface vs type, structural typing and excess-property checks, intersections, discriminated unions, enums versus literal unions, and satisfies.
- Course: JavaScript study plan
- Module: TypeScript preview
- Kind: Lesson
- Reading time: 13 min
- Runtime: Node 16
What is the difference between interface and type in TypeScript?
In TypeScript, an interface names an object shape that can be extended with extends and merged across declarations, which is how libraries augment globals. A type alias can name anything — unions, tuples, primitives, function types, mapped and conditional types — and combines shapes with &. A common convention is interface for object shapes that may be extended and type for unions and computed types.
Lesson
Most of a TypeScript program's types describe objects: what properties they have, which are optional or read-only, what shape each is. Two features decide how those types behave and both surprise newcomers from Java or C#: types are structural (a value matches a type if it has the right shape, whatever it was declared as), and the natural way to model "one of several kinds of thing" is not a class hierarchy but a discriminated union — a kind field the compiler can switch on. This lesson covers object types and interfaces, optional/readonly/index signatures, type versus interface, structural assignability and excess-property checks, intersections, discriminated unions, enum versus literal unions, and satisfies.
Object types
interface User {
id: string;
name: string;
email?: string; // optional: string | undefined
readonly createdAt: Date; // cannot be reassigned after construction
tags: readonly string[]; // a readonly array: no push/pop/assignment by index
[extra: string]: unknown; // index signature: any other string key holds an unknown
}
type Point = { x: number; y: number }; // type alias — same for object shapes
Optional (?) means the property may be absent or undefined; under exactOptionalPropertyTypes the two are distinguished. readonly is compile-time only (no Object.freeze). An index signature says "any key of this form maps to this type" — for dictionaries, though Record<string, T> or a Map is usually clearer. Method syntax greet(): string and property syntax greet: () => string both work; the latter is stricter about parameter variance.
interface versus type
Both name an object shape. interface can be extended (interface Admin extends User { role: "admin" }), merged across declarations (two interface Window blocks combine — how libraries augment globals), and gives clearer error messages for object shapes. type can name anything — unions, tuples, primitives, function types, mapped and conditional types — and uses & for intersection. Convention in most codebases: interface for object shapes that may be extended or implemented by classes, type for unions, aliases and computed types. Either is fine; be consistent.
Structural typing
interface Named { name: string }
const dog = { name: "Rex", legs: 4 };
const n: Named = dog; // ok — dog HAS a name; extra properties are fine for a non-literal
function greet(x: Named) {}
greet({ name: "Ada", age: 36 }); // error: excess property 'age' — a fresh object literal is checked exactly
greet(dog); // ok — the same object through a variable
A type is a set of required properties; any value with those (and any others) is assignable. Names do not matter — two interfaces with identical members are interchangeable. This is why a plain object can satisfy an interface without implements, why library types compose without inheritance, and why "duck typing" is checked statically. The one place TypeScript is nominal-ish is excess property checking: passing an object literal directly to a typed slot flags unknown properties (usually a typo), while the same object through a variable is accepted. Classes are structural too — class A { x = 1 } and class B { x = 1 } are assignable to each other — unless they have private members, which are compared by declaration.
Intersections and composition
type Timestamped = { createdAt: Date; updatedAt: Date };
type Post = { title: string; body: string } & Timestamped; // has all five properties
type Handler = ((e: Event) => void) & { displayName: string }; // a callable with a property
& combines shapes; conflicting property types intersect too (string & number is never — a common source of "why is this never"). Intersections compose behaviour-less data shapes the way mixins compose classes.
Discriminated unions
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "square"; size: number };
function area(s: Shape): number {
switch (s.kind) { // the discriminant: a literal-typed property present in every member
case "circle": return Math.PI * s.radius ** 2; // s is narrowed to the circle member here
case "rect": return s.width * s.height;
case "square": return s.size ** 2;
}
}
Each member carries a literal kind (or type, status, tag); a switch/if on it narrows the union so the member's own properties become available. This is the idiomatic replacement for class hierarchies with instanceof, for "objects with a type string and a bag of optional fields", and for state modelling ({ status: "loading" } | { status: "error"; error: Error } | { status: "ready"; data: T } — impossible states become unrepresentable). Add the never default (previous lesson) for exhaustiveness. Result-style types (module 11) are discriminated unions on ok: true | false.
Unions of primitives and literal unions
type Id = string | number — narrow with typeof. type Method = "GET" | "POST" | "PUT" | "DELETE" — a closed set of strings the compiler completes and checks; combine with as const arrays to derive the type from the data: const METHODS = ["GET", "POST"] as const; type Method = (typeof METHODS)[number];.
enum — and why unions usually win
enum Color { Red, Green } // numeric: Color.Red === 0, and any `number` value is assignable — weak
enum Level { Low = "low", High = "high" } // string enum: a real runtime object, nominal
Enums generate runtime code (an object), are nominal (a "low" string is not assignable to Level), and numeric enums accept arbitrary numbers. String-literal unions are erased, structural, need no import at call sites, and give the same autocompletion. Most modern style guides prefer unions (or as const objects) and reserve enums for interop with existing enum-shaped data. const enum inlines values but breaks under isolated-module builds — avoid.
satisfies
const palette = { primary: "#2563EB", muted: "#64748B" } satisfies Record<string, string>;
palette.primary.toUpperCase(); // still knows primary is a string literal-typed key: inference kept, shape validated
satisfies (4.9) checks a value against a type without widening the value to that type — you get validation and keep the precise inferred type. Before it, you chose between const p: Record<string, string> = … (loses the exact keys) and no check at all.
Common mistakes
- Expecting a class name to matter for assignability (it does not — shape does).
- Fighting excess-property errors by widening the type instead of fixing the typo.
- Optional-field bags (
{ radius?: number; width?: number; … }) instead of a discriminated union. string & numberintersections that collapse tonever.- Numeric enums;
const enumin bundled projects. - Annotating a config object with a wide type and losing literal keys (use
satisfiesoras const).
Interview angle
- "Is TypeScript nominally or structurally typed?" Structurally — shape decides assignability; excess-property checks on fresh literals and private class members are the exceptions.
- "
interfaceversustype?" Interfaces extend and merge and suit object shapes; types alias anything including unions; pick a convention. - "What is a discriminated union?" A union whose members share a literal-typed tag; switching on it narrows to the member — the idiomatic alternative to class hierarchies and optional bags.
- "Why prefer string-literal unions to enums?" Erased, structural, no imports, same autocompletion; enums are nominal runtime objects and numeric ones are unsafe.
- "What does
satisfiesdo?" Validates a value against a type while keeping the value's inferred (narrower) type.
Key takeaways
- Object types:
?optional,readonly, index signatures;interfaceextends/merges,typealiases anything; be consistent. - Structural typing: shape is everything; fresh literals get excess-property checks; private members make classes nominal.
&intersects shapes (conflicts becomenever); discriminated unions with a literalkindmodel variants and states — switch to narrow,neverto exhaust.- String-literal unions (with
as constsources) over enums. satisfiesvalidates without widening;as constkeeps literals.
Common questions
Is TypeScript structurally or nominally typed?
Structurally. A value is assignable to a type if it has the required properties, whatever it was declared as, so two interfaces with identical members are interchangeable. The exceptions are excess-property checks on fresh object literals and classes with private members, which are compared by declaration.
What is a discriminated union in TypeScript?
A union of object types that share a literal-typed tag such as kind or status. A switch or if on the tag narrows the value to one member, so that member's own properties become available. It is the idiomatic replacement for class hierarchies and bags of optional fields.
What does "Object literal may only specify known properties" mean?
It is TypeScript's excess-property check: an object literal written directly where a type is expected may not carry properties the type does not declare — usually a typo. The same object assigned to a variable first is accepted, because structural typing allows extra properties.
Should I use an enum or a union of string literals in TypeScript?
Most style guides prefer string-literal unions such as "low" | "high": they are erased at build, structural, need no import and autocomplete just as well. Enums generate a runtime object and are nominal, and numeric enums let plain numbers through; keep enums for interop with existing enum-shaped data.
What does the satisfies operator do in TypeScript?
satisfies, added in TypeScript 4.9, checks a value against a type without widening the value to that type. const palette = { primary: "#2563EB" } satisfies Record<string, string> is validated, yet the compiler still knows its exact keys — an annotation would have widened it to Record<string, string>.
Exercises
Structural assignability, with the excess-property rule
Line 1 is a type as JSON — property → type, where a trailing ? marks optional and types are string, number, boolean or T[]. Each following line is fresh <json> (an object literal passed directly) or var <json> (the same object via a variable). Report, in property order: Property 'k' is missing for absent required properties; Type '<actual>' is not assignable to type '<expected>' at 'k' for mismatches (an array's actual type is the union of its element types plus []); and — for fresh only — Object literal may only specify known properties, and 'k' does not exist. Print <mode> <JSON>: ok or … : not assignable - <problems joined by ; >.
Example with {"name":"string","age":"number","email?":"string","tags":"string[]"}:
fresh {"name":"Ada","age":36,"tags":["a"],"zip":"123"}: not assignable - Object literal may only specify known properties, and 'zip' does not exist
var {"name":"Ada","age":36,"tags":["a"],"zip":"123"}: ok
fresh {"name":"Ada","age":"36","tags":[1]}: not assignable - Type 'string' is not assignable to type 'number' at 'age'; Type 'number[]' is not assignable to type 'string[]' at 'tags'
The same object is rejected as a fresh literal and accepted through a variable — structural typing with one nominal-ish exception.
Dispatch on a discriminant, exhaustively
Write area(shape) for { kind: "circle", r } | { kind: "rect", w, h } | { kind: "square", s } and render(state) for { status: "loading" } | { status: "error", error } | { status: "ready", data } (→ spinner, error banner: <error>, list of <data.length>), each a switch on the discriminant with assertNever in the default (it throws unhandled variant: <JSON>). For each JSON line: objects with kind print <kind>: area=<2dp>, objects with status print <status>: <render>, anything else no discriminant: cannot dispatch; thrown errors print error: <message>.
Example: {"kind":"circle","r":1}, {"kind":"triangle","b":1,"h":2}, {"status":"error","error":"timeout"}, {"status":"paused"} →
circle: area=3.14
error: unhandled variant: {"kind":"triangle","b":1,"h":2}
error: error banner: timeout
error: unhandled variant: {"status":"paused"}In this module: TypeScript preview
- What TypeScript adds — erased types, inference, and any versus unknown versus never
- Object types, interfaces, structural typing and discriminated unions (this lesson)
- Function types, generics, keyof and the utility types
- Narrowing, type guards, assertions — making the compiler trust a value
- TypeScript in practice — tsconfig, declaration files, builds, migration and the errors you will see
- Checkpoint — TypeScript preview
← What TypeScript adds — erased types, inference, and any versus unknown versus never · Function types, generics, keyof and the utility types →