TypeScript From Zero
Objects, Interfaces, Type Aliases and Classes
Object types, interface versus type, structural typing, excess-property checks, classes, enums versus unions, as const and satisfies.
On this page
In one line: an object type says which properties a value has and what type each one is. TypeScript checks objects by their shape (structural typing), not by their name, so interface and type are used in almost the same way. Learn the few real differences, the excess-property check that catches typos, how classes add access control that is erased at runtime, and the three small tools for fixed sets of values: literal unions, as const and satisfies.
In Lesson 1 you used primitives, unions and arrays. Most real data is an object with named fields: a user, an order, a config. This lesson shows how to describe objects, how TypeScript decides if one object fits a type, and how to write classes. If you have not set up a project, start at Lesson 0. Interview versions of these topics are in Full-Stack Q&A, Lesson 3 and the advanced ones in Frontend Depth, Lesson 4. Functions and generics come next, in Lesson 3.
What I ran / checked. Everything here was compiled with TypeScript 7.0.2 (tsc --pretty false) on Node v22.22.0, with the same tsconfig as Lessons 0 and 1 (strict, noUncheckedIndexedAccess, verbatimModuleSyntax, erasableSyntaxOnly, noEmit). Error messages are copied from the compiler. Programs that print something were run with node file.ts and the output is quoted. The enum JavaScript output was produced by a separate tsc call with the config ignored (I say so where it matters). I did not measure compile speed, so I make no claim about interface being faster than type. I did not read the TypeScript handbook for this lesson; the behaviour described is what I observed.
Words used in this lesson
- Object type is a type that lists the properties of an object and the type of each.
- Property is a named value on an object (
user.name). A method is a property that holds a function. - Optional property (
email?: string) may be missing. Readonly property cannot be assigned to after the object is made. - Index signature (
[key: string]: number) says "any number of properties with any string name, all of this type". - Structural typing means two types are compatible when their shapes fit, whatever their names are. The opposite is nominal typing, where names must match (Java and C# work this way).
- Excess property check is the extra check TypeScript does when you write an object literal straight into a typed place: unknown properties are errors.
- Intersection (
A & B) is a type that must satisfy bothAandB. - Declaration merging means two
interfacedeclarations with the same name in the same scope are joined into one. - Access modifier is
public,privateorprotectedon a class member. They exist only at compile time. - Parameter property is a constructor parameter with a modifier (
constructor(public x: number)) that also creates a field. - Enum is a TypeScript feature that makes a set of named constants as a real runtime object.
as constmakes a value as narrow and read-only as possible.satisfieschecks a value against a type without changing the value's own type.
Object types
The most common way to write an object type is a type alias with braces:
type User = { id: number; name: string; email?: string; readonly createdAt: Date;};const u: User = { id: 1, name: "Ada", createdAt: new Date(0) };u.name = "Ada L.";u.createdAt = new Date();console.log(u.email.toLowerCase());const missing: User = { id: 2, name: "Bo" };const wrong: User = { id: "3", name: "Cy", createdAt: new Date(0) };src/a1.ts(10,3): error TS2540: Cannot assign to 'createdAt' because it is a read-only property.src/a1.ts(11,13): error TS18048: 'u.email' is possibly 'undefined'.src/a1.ts(12,7): error TS2741: Property 'createdAt' is missing in type '{ id: number; name: string; }' but required in type 'User'.src/a1.ts(13,23): error TS2322: Type 'string' is not assignable to type 'number'.Four different rules, four different messages:
u.name = ...on line 9 was fine, becausenameis a normal property.- A readonly property can be set when the object is made, and never after (
TS2540). - An optional property (
email?) has the typestring | undefined. You must check it before use (TS18048), the same narrowing as Lesson 1. A safe read isu.email?.toLowerCase() ?? "no email". - A required property that is missing is an error (
TS2741), and so is the wrong type (TS2322).
readonly is a compile-time rule only. It stops your code from assigning, but the value is a normal mutable JavaScript object when the program runs. Data that comes in from JSON is not "frozen" because you wrote readonly. It is a promise between you and the checker.
Readonly arrays
The same idea for lists: readonly number[] (or ReadonlyArray<number>) hides the methods that change the array. It is a good way to tell callers "I will not modify what you give me":
function total(xs: readonly number[]): number { xs.push(1); xs[0] = 5; return xs.reduce((a, b) => a + b, 0);}const ro: readonly string[] = ["a"];const mutable: string[] = ro;src/m1.ts(2,6): error TS2339: Property 'push' does not exist on type 'readonly number[]'.src/m1.ts(3,3): error TS2542: Index signature in type 'readonly number[]' only permits reading.src/m1.ts(7,7): error TS4104: The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.A normal string[] can be passed where readonly string[] is wanted, but not the other way round, as the last error shows.
Index signatures
When you do not know the property names in advance, such as a dictionary of scores by name, use an index signature:
const scores: { [name: string]: number } = { ada: 90, bo: 85 };const s2 = scores.zed;const r: string = s2;scores.cy = "high";type Config = { retries: number; [key: string]: number };const bad: Config = { retries: 3, label: "x" };src/a3.ts(3,7): error TS2322: Type 'number | undefined' is not assignable to type 'string'. Type 'undefined' is not assignable to type 'string'.src/a2.ts(5,1): error TS2322: Type 'string' is not assignable to type 'number'.src/a2.ts(8,35): error TS2322: Type 'string' is not assignable to type 'number'.(These came from two small files. I put them in one block to save space.) Three things to see:
- Reading
scores.zedgivesnumber | undefined, notnumber. That isnoUncheckedIndexedAccessfrom Lesson 0: the key might not be there. Without that option TypeScript would saynumber, andscores.zed.toFixed()would crash at runtime. - Every value must match the index type, including the fixed properties.
Configsays thatretriesis anumber, and it also says every other property is anumber, solabel: "x"fails. If you need a mix of types, make the index type a union:[key: string]: number | string. - An index signature means the object can have any key. When the keys are a known set, use
Recordwith a union of keys, and TypeScript makes sure none is missing:
const lookup: Record<"a" | "b", number> = { a: 1 };src/a2.ts(10,7): error TS2741: Property 'b' is missing in type '{ a: number; }' but required in type 'Record<"a" | "b", number>'.For a dictionary where you add and remove keys at run time, a Map<string, number> is often the better tool (it also avoids names like toString clashing with object properties). Record is one of the built-in utility types; Lesson 4 covers them all.
interface or type
Both can describe an object:
interface Point { x: number; y: number }type Pt = { x: number; y: number };For plain objects they behave almost the same. A function that wants a Point accepts a Pt, because of structural typing (next section). The real differences are small and you can list them:
interface | type alias | |
|---|---|---|
| Describes an object or a class shape | Yes | Yes |
| Can name a union, a tuple, a primitive or a function type | No | Yes |
| Declared twice with the same name | Merges into one | Error |
| Extends another type | extends | & (intersection) |
| Can be used with mapped and conditional types (Lesson 4) | No, only the object shapes | Yes |
The merge difference
interface Point { x: number; y: number }interface Point { z?: number }const p: Point = { x: 1, y: 2, z: 3 };type Pt = { x: number };type Pt = { y: number };src/b1.ts(5,6): error TS2300: Duplicate identifier 'Pt'.src/b1.ts(6,6): error TS2300: Duplicate identifier 'Pt'.The two interface Point lines joined into one type with x, y and z, with no error (the p line compiled). The two type Pt lines are an error. Merging is the reason libraries use interface for types you are expected to extend: you can add a property to a library's interface from your own file (this is called "augmenting" and is covered in Lesson 5). It is also the reason a typo in an interface name can silently create a second interface, instead of an error.
Union, tuple and function types need type
type ID = string | number;type Pair = [string, number];type Fn = (n: number) => string;interface Fn2 { (n: number): string }All four compile. ID and Pair have no interface form. A function type can be written either way, and the type version is shorter. If you need to attach properties to a function (a function with a label), the interface call-signature form is the clear one:
interface Cb { (n: number): string; label: string }const cb: Cb = Object.assign((n: number) => String(n), { label: "x" });console.log(cb(5), cb.label);5 xExtending: extends and &
interface Animal { name: string; legs: number }interface Dog extends Animal { legs: 4; bark(): string }interface Bad extends Animal { legs: string }type Named = { name: string };type Aged = { age: number };type Person = Named & Aged;const person: Person = { name: "Ada" };src/b1.ts(10,11): error TS2430: Interface 'Bad' incorrectly extends interface 'Animal'. Types of property 'legs' are incompatible. Type 'string' is not assignable to type 'number'.src/b1.ts(15,7): error TS2322: Type '{ name: string; }' is not assignable to type 'Person'. Property 'age' is missing in type '{ name: string; }' but required in type 'Aged'.Dog narrowed legs from number to 4. That is allowed, because every Dog is still a valid Animal. Bad changed legs to a type that is not compatible, and interface extends refuses, and it names the property. This is a real benefit of extends: an interface reports the conflict at the place where you declare it.
An intersection (&) does not stop you. It combines the types, and if two properties have incompatible types, the property becomes never:
type A = { id: string };type B = { id: number };type AB = A & B;type IdOfAB = AB["id"];const probe: IdOfAB = "a";src/b1.ts(27,7): error TS2322: Type '"a"' is not assignable to type 'never'.AB["id"] is string & number, which no value can have. There was no error where I wrote A & B; the problem shows up only later, when I use id. So extends gives an earlier, clearer error for the same mistake.
A simple rule I use. interface for the shape of an object or a class that other code may extend; type for everything else (unions, tuples, function types, and combinations). Pick one style for object shapes in a project and keep to it. The editor's hover text and the error messages show the interface's name, while an alias is sometimes expanded. I did not measure any speed difference and I would not decide on speed.
Structural typing
TypeScript asks one question: does this value have what the type needs? The name of the type does not matter.
interface Named { name: string }function hello(x: Named) { return "hi " + x.name; }const dog = { name: "Rex", legs: 4 };console.log(hello(dog));dog was never declared as Named, and it has an extra property, and it is accepted. It has a name that is a string, and that is all hello needs. This is what makes TypeScript fit well on top of JavaScript, where objects are made ad hoc.
Classes follow the same rule:
class Meters { value = 0; }class Feet { value = 0; }const m: Meters = new Feet();console.log(m instanceof Meters);tsc printed no error for these lines, because Feet has the same shape as Meters. At runtime, m instanceof Meters is false. If you need two types to be incompatible on purpose (for example, a UserId and an OrderId that are both strings), you add a tag, called a brand: type UserId = string & { readonly __brand: "UserId" }. I did not run a brand example here; Lesson 4 returns to this idea.
The excess property check
There is one place where TypeScript is stricter than the "does it have what is needed" rule. When you write an object literal directly into a typed place, extra properties are errors:
console.log(hello({ name: "Rex", legs: 4 }));interface Options { color?: string; width?: number }function draw(o: Options) { return o; }draw({ colour: "red" });const o2 = { colour: "red" };draw(o2);const o4 = { size: 2 };draw(o4);src/c1.ts(6,34): error TS2353: Object literal may only specify known properties, and 'legs' does not exist in type 'Named'.src/c1.ts(11,8): error TS2561: Object literal may only specify known properties, but 'colour' does not exist in type 'Options'. Did you mean to write 'color'?src/c1.ts(13,6): error TS2559: Type '{ colour: string; }' has no properties in common with type 'Options'.src/c1.ts(16,6): error TS2559: Type '{ size: number; }' has no properties in common with type 'Options'.Look at what happened with hello. The same object, { name: "Rex", legs: 4 }, passed through the variable dog was fine and, written as a literal inside the call, is an error (TS2353). The reason: in the literal, you wrote legs yourself, so it is almost certainly a mistake or a typo. A variable could have come from anywhere.
Three more things from this output:
- For
draw({ colour: "red" })the compiler even suggests the fix:Did you mean to write 'color'?. Optionshas only optional properties. TypeScript calls this a weak type, and it adds one more rule: if you pass something that shares no property with it, that is an error (TS2559), even through a variable. Otherwisedraw(o4)would pass, because everything is optional.- The excess check is skipped when you use a cast:
hello({ name: "Rex", legs: 4 } as Named)compiled. A cast turns off a useful check, so only use it when you must.
Classes
A class is a way to make many objects with the same fields and methods. TypeScript adds types, access modifiers and a few checks to JavaScript classes.
class Account { readonly id: number; owner: string; private balance: number; protected kind = "basic"; #pin = 1234; constructor(id: number, owner: string, balance = 0) { this.id = id; this.owner = owner; this.balance = balance; } deposit(n: number): void { this.balance += n; } get total(): number { return this.balance; } checkPin(p: number): boolean { return p === this.#pin; }}const acc = new Account(1, "Ada", 10);acc.deposit(5);console.log(acc.total, acc.checkPin(1234));console.log(acc.balance);console.log(acc.kind);acc.id = 2;console.log(acc.#pin);src/d1.ts(21,17): error TS2341: Property 'balance' is private and only accessible within class 'Account'.src/d1.ts(22,17): error TS2445: Property 'kind' is protected and only accessible within class 'Account' and its subclasses.src/d1.ts(23,5): error TS2540: Cannot assign to 'id' because it is a read-only property.src/d1.ts(24,17): error TS18013: Property '#pin' is not accessible outside class 'Account' because it has a private identifier.| Modifier | Who can use it | Real at runtime? |
|---|---|---|
public (the default) | everyone | n/a |
protected | the class and its subclasses | No, compile time only |
private | the class only | No, compile time only |
#name | the class only | Yes, it is JavaScript's own private field |
readonly | cannot be assigned after the constructor | No, compile time only |
The word private is only a note to the checker. I proved it by going around the checker with as any and printing the result with Node:
const acc = new Account(1, 10);(acc as any).balance = 999;console.log(acc.total);console.log(Object.keys(acc));console.log(JSON.stringify(acc));999[ 'id', 'balance' ]{"id":1,"balance":999}The "private" balance was changed, it appears in Object.keys, and JSON.stringify prints it. The #pin field is hidden from both (it is not in the list). If a value is a real secret in a program that runs where others can inspect it, use # fields or closures, and not private.
Parameter properties, and the flag from Lesson 0
TypeScript has a shortcut: a modifier on a constructor parameter declares the field and assigns it.
class Point { constructor(public x: number, public y: number) {}}Under our tsconfig, this is an error:
src/d2.ts(2,15): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.src/d2.ts(2,33): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.This is the same flag from Lesson 0. A parameter property needs code to be generated (the assignment this.x = x), so a tool that only deletes types cannot run it. Node's node src/d2.ts says TypeScript parameter property is not supported in strip-only mode. npx tsx src/d2.ts runs it and printed Point { x: 1, y: 2 }, and with --erasableSyntaxOnly false tsc accepts it too. So the shortcut is fine if your tool chain transforms TypeScript (Next.js, Vite, tsx). In this series, I write the fields out, as in Account above. It is longer and works everywhere.
Abstract classes and implements
abstract class Shape { abstract area(): number; describe() { return "area " + this.area(); }}const s = new Shape();interface HasArea { area(): number }class Circle implements HasArea { area() { return 3; }}class Broken implements HasArea { size() { return 1; }}src/d3.ts(9,11): error TS2511: Cannot create an instance of an abstract class.src/d3.ts(14,7): error TS2420: Class 'Broken' incorrectly implements interface 'HasArea'. Property 'area' is missing in type 'Broken' but required in type 'HasArea'.An abstract class is a base that cannot be created directly. It can hold shared code (describe) and require the subclasses to fill in the rest (area). implements makes the compiler check that a class has everything an interface needs. It does not copy any code, and it does not change the class's type; implements is only a check. (A class may also implement a type alias of an object shape; I tested class D implements Ts where Ts is a type alias, and it compiled.)
Two classic class errors
A field with no starting value. strict includes strictPropertyInitialization:
class Box { value: number;}src/d4.ts(2,3): error TS2564: Property 'value' has no initializer and is not definitely assigned in the constructor.Give it a value (value = 0), assign it in the constructor, or make it optional (value?: number).
Losing this. In JavaScript a method forgets its object if you take it out:
class Btn { label = "ok"; click = () => this.label; method() { return this.label; }}const b = new Btn();const { click, method } = b;console.log(click());try { console.log(method()); } catch (e) { console.log(String(e)); }okTypeError: Cannot read properties of undefined (reading 'label')tsc printed no error for this. The arrow-function field click captured this when the object was made, so it works. The normal method method lost its this when it was pulled out of b. Lesson 3 shows how a this parameter catches some of these cases.
Enums or unions of literals
An enum makes a set of named constants:
enum Role { Admin, User }enum Level { Low = "low", High = "high" }console.log(Role.User, Role[0], Level.High);Under our tsconfig, tsc says TS1294 for the first line (erasableSyntaxOnly). To see what an enum becomes, I compiled this file alone, ignoring the project config:
var Role;(function (Role) { Role[Role["Admin"] = 0] = "Admin"; Role[Role["User"] = 1] = "User";})(Role || (Role = {}));var Level;(function (Level) { Level["Low"] = "low"; Level["High"] = "high";})(Level || (Level = {}));console.log(Role.User, Role[0], Level.High);1 Admin highAn enum is real code: an object is created at runtime. A numeric enum is built in both directions (Role[0] gives "Admin"). That is also why Node's type stripping cannot run it. One more surprise: in TypeScript 7.0.2 this is an error, but I remember older versions accepted it:
enum Role { Admin, User }const r: Role = 99;src/e1.ts(2,7): error TS2322: Type '99' is not assignable to type 'Role'.The usual replacement is a union of string literals, which costs nothing at runtime:
type Status = "idle" | "loading" | "done";If you also want an object of the values, to loop over or to show in a menu, use as const and derive the union from the object:
const Status2 = { Idle: "idle", Loading: "loading" } as const;type Status2 = (typeof Status2)[keyof typeof Status2];let s: Status2 = "loading";s = "other";src/e1.ts(10,7): error TS2322: Type '"other"' is not assignable to type 'Status2'.(A value and a type can share a name, which is why Status2 is both. typeof Status2 is the type of the object; keyof lists its keys; indexing with those keys gives the union of the values. Lesson 3 explains these three.)
| Enum | Union of literals | as const object | |
|---|---|---|---|
| Exists at runtime | Yes (extra JavaScript) | No | Yes, a plain object |
Works with Node type stripping and erasableSyntaxOnly | No | Yes | Yes |
| Loop over the values | Yes | No | Yes |
| Message in an error | Role | the literal values | the literal values |
My default: a union of literals. If I need the values at runtime, as const. I use an enum only when the code base already uses them.
as const
as const tells TypeScript to treat a value as literally as possible: literal types, readonly properties, and tuples instead of arrays.
const plain = { a: 1, tags: ["x", "y"] };const frozen = { a: 1, tags: ["x", "y"] } as const;frozen.a = 2;frozen.tags.push("z");const probe1: never = plain;const probe2: never = frozen;src/e1.ts(14,8): error TS2540: Cannot assign to 'a' because it is a read-only property.src/e1.ts(15,13): error TS2339: Property 'push' does not exist on type 'readonly ["x", "y"]'.src/e1.ts(16,7): error TS2322: Type '{ a: number; tags: string[]; }' is not assignable to type 'never'.src/e1.ts(17,7): error TS2322: Type '{ readonly a: 1; readonly tags: readonly ["x", "y"]; }' is not assignable to type 'never'.The last two errors are the "reveal the type" trick from Lesson 1. Compare the two shapes: plain is { a: number; tags: string[] }, and frozen is { readonly a: 1; readonly tags: readonly ["x", "y"] }. Note that as const solves the Lesson 1 problem of { dir: "up" } being widened to string.
A list of allowed values is the most useful case. One source gives you both the runtime list and the type:
const DIRS = ["up", "down", "left"] as const;type Dir = (typeof DIRS)[number];const d: Dir = "up";console.log(DIRS.length, d, DIRS.includes("up"));3 up trueDir is "up" | "down" | "left". Add a direction to the array and the type updates by itself.
satisfies
Sometimes you want to check that a value fits a type, but keep the precise type of the value. A type annotation (: Type) does the check and then throws away the precision:
type Color = "red" | "green" | "blue";type Rgb = [number, number, number];const palette1: Record<Color, string | Rgb> = { red: "#f00", green: [0, 255, 0], blue: "#00f",};palette1.green.map((n) => n);src/e2.ts(9,16): error TS2339: Property 'map' does not exist on type 'string | Rgb'. Property 'map' does not exist on type 'string'.src/e2.ts(9,21): error TS7006: Parameter 'n' implicitly has an 'any' type.palette1.green is now string | Rgb, although we can see it is an array. With satisfies, the check still happens, but the value keeps its own type:
const palette2 = { red: "#f00", green: [0, 255, 0], blue: "#00f",} satisfies Record<Color, string | Rgb>;palette2.green.map((n) => n * 2);palette2.red.toUpperCase();const palette3 = { red: "#f00", green: [0, 255, 0], bleu: "#00f",} satisfies Record<Color, string | Rgb>;src/e2.ts(22,3): error TS2353: Object literal may only specify known properties, and 'bleu' does not exist in type 'Record<Color, string | Rgb>'.palette2 compiled: green is a number array and red is a string. palette3 has a typo in a key, and satisfies found it. Compare with a cast (as), which is not a check at all:
const asCast = { red: "#f00", green: [0, 255, 0], blue: 5 } as Record<Color, string | Rgb>;src/e2.ts(25,16): error TS2352: Conversion of type '{ red: string; green: [number, number, number]; blue: number; }' to type 'Record<Color, string | Rgb>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Types of property 'blue' are incompatible. Type 'number' is not comparable to type 'string | Rgb'.A cast is allowed when the types "sufficiently overlap", which is looser than assignment. Here the compiler caught a case that was far off, but a smaller mistake would pass silently. Prefer satisfies when you only want to check.
The two combine well for config objects:
type Route = { path: string; method: "GET" | "POST" };const routes = { home: { path: "/", method: "GET" }, save: { path: "/save", method: "POST" },} as const satisfies Record<string, Route>;type RouteName = keyof typeof routes;const n: RouteName = "home";const wrongRoutes = { x: { path: "/", method: "PUT" },} as const satisfies Record<string, Route>;src/n1.ts(11,19): error TS2322: Type '"PUT"' is not assignable to type '"GET" | "POST"'.Each route is checked, and routes.home keeps the type { readonly path: "/"; readonly method: "GET" }. RouteName is "home" | "save".
Try it
- Write
type Product = { id: number; title: string; price: number; tags?: string[] }. Create a product withouttags. What type doesp.tagshave, and what doesp.tags.lengthsay? - Write
interface Named { name: string }andfunction greet(x: Named). Call it with a variable that has an extra property, then with the same object written inline. What does each do? - Define
interface Animal { name: string }andinterface Dog extends Animal { name: number }. What doestscsay? Then write the same withtypeand&. Where does the error show up? - Write a class
Counterwith aprivate count = 0, a methodinc(), and a gettervalue. Readcounter.countfrom outside. What doestscsay? Then use(counter as any).count = 100and printcounter.value. - Replace
enum Level { Low, High }with a version that has a runtime object and a type, usingas const. - Write
const sizes = { s: 1, m: 2 } satisfies Record<string, number>. What is the type ofsizes.s? Compare with the annotation: Record<string, number>.
Answers.
p.tagsisstring[] | undefined.p.tags.lengthgivesTS18048: 'p.tags' is possibly 'undefined'.Usep.tags?.length ?? 0.- The variable is accepted. The inline literal fails with
TS2353: Object literal may only specify known properties, and 'x' does not exist in type 'Named'.(The property name in the message is the extra one you wrote.) - With
interface Dog extends Animal, the error is at the interface:TS2430: Interface 'Dog' incorrectly extends interface 'Animal'.Withtype Dog = Animal & { name: number }there is no error at the definition;namebecomesneverand the problem shows only when you use it. TS2341: Property 'count' is private and only accessible within class 'Counter'.After theas anywrite,counter.valueprints100, becauseprivateis erased.const Level = { Low: 0, High: 1 } as const; type Level = (typeof Level)[keyof typeof Level];- With
satisfies,sizes.sisnumber(I checked with thenevertrick), and the object keeps only its own keyssandm. With the annotationRecord<string, number>, you can read any key such assizes2.zzz, and its type isnumber | undefinedwithnoUncheckedIndexedAccess. I ran both.
Interview questions
What is the difference between interface and type? (Must know)
For object shapes they work almost the same. The real differences: an interface can be declared twice and the parts merge, while a type declared twice is an error (TS2300, which I got). A type can name unions, tuples, primitives and function types, while an interface cannot. An interface uses extends, and when a property clashes the error is at the declaration; with & there is no error there and the property becomes never. My rule: interface for object shapes that others may extend, type for the rest, and I stay consistent in a project.
What is structural typing? (Must know)
TypeScript checks the shape, not the name. If a value has the properties a type needs, it fits, even if it was never declared as that type. In my test an object { name, legs } was accepted where Named was needed, and a Feet class was accepted where a Meters class was needed because both had a value number. The downside is that two different ideas with the same shape are interchangeable unless I add a brand.
What is the excess property check, and when does it apply? (Must know)
It applies when I write an object literal directly where a type is expected. Unknown properties are then an error, which catches typos: draw({ colour: "red" }) gave TS2561 and the suggestion Did you mean to write 'color'?. It does not apply when the object comes through a variable, or when I use a cast. So the same object can pass or fail depending on how I pass it.
What is a "weak type" error (TS2559)? (Senior signal)
A weak type is an object type with only optional properties. If I pass something with no property in common, such as { size: 2 } to Options { color?; width? }, TypeScript refuses, even through a variable. Without this rule everything would match, because every property is optional.
What do private and #private do differently? (Must know)
private is a compile-time check only. In my test, (acc as any).balance = 999 worked, and the field showed up in Object.keys and JSON.stringify. #pin is JavaScript's own private field: it is enforced at runtime and does not show up in either. So private is for design, and # is for real hiding.
Why does erasableSyntaxOnly reject parameter properties and enums? (Senior signal)
Both need code to be generated: a parameter property becomes an assignment in the constructor, and an enum becomes a function that builds an object (I printed that output). Tools that only delete types, such as Node's type stripping, cannot do that. With the flag on, tsc reports TS1294; without it, Node reports ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. I use plain fields and a union of literals instead.
Enum or union of string literals? (Must know)
I default to a union of literals: no runtime code, works with type stripping, and the error messages show the allowed words. If I also need the list at runtime, I use an as const object and derive the type with (typeof X)[keyof typeof X]. Enums create runtime objects, numeric enums are also built in reverse (Role[0]), and I only use them where the code base already does.
What does as const do? (Must know)
It makes the value read-only and as literal as possible. { a: 1, tags: ["x", "y"] } is { a: number; tags: string[] }, and with as const it is { readonly a: 1; readonly tags: readonly ["x", "y"] }. I use it to keep literal types in an object (so { dir: "up" } is not widened to string), and for a list of allowed values to derive a union type from.
What is satisfies, and how is it different from a type annotation or as? (Senior signal)
A type annotation checks the value and then replaces its type with the annotation, so I lose precision (palette1.green became string | Rgb). as is not a check; it is an instruction, and only refuses when the types barely overlap. satisfies checks that the value fits the type, reports excess keys (it found my bleu typo), and keeps the value's own precise type (palette2.green.map worked).
What is an index signature, and what are its risks? (Must know)
It says "any string key, all values of this type". The risks: reading a missing key gives undefined at runtime, and without noUncheckedIndexedAccess the type would still say number. With the flag, I got number | undefined. Also, every fixed property must fit the index type (label: "x" failed in { retries: number; [k: string]: number }). For a known set of keys I use Record<"a" | "b", number>; for a dynamic dictionary I consider a Map.
Does readonly make an object immutable? (Trap)
No. readonly is checked at compile time only, so the object is an ordinary mutable object at runtime, and any code that bypasses the types (an as any, or plain JavaScript) can change it. For real immutability I would use Object.freeze. I did not run a freeze example here. readonly is still useful: readonly number[] removes push and index writes for the caller, so a function can promise not to change its input.
Why can this crash even though tsc passes: const { method } = obj; method()? (Trap)
A normal class method uses this, and taking it out of the object loses the object. In my test the class had label = "ok"; the arrow-function field click returned ok, and the method gave TypeError: Cannot read properties of undefined (reading 'label'), with no tsc error. Fixes: use an arrow-function field, call method.bind(obj), or keep calling it as obj.method().
Key takeaways
- Object types list properties.
?makes one optional (its type gets| undefined),readonlyblocks assignment (compile time only). - Index signatures allow any key; reading gives
| undefinedwithnoUncheckedIndexedAccess. UseRecord<Union, T>for a known set of keys. interfacemerges and gives early errors onextends.typecan name unions, tuples and functions. For plain objects the choice is mostly style.- TypeScript is structural: shape decides, not name. Literals get an extra excess-property check, and all-optional types get the weak-type check.
- Class modifiers
private,protectedandreadonlyare erased;#fieldis really private. Parameter properties and enums need code generation, soerasableSyntaxOnlyrejects them. - Prefer a union of literals to an enum. Use
as constfor fixed lists and objects, andsatisfiesto check without losing precision.
What's next
Lesson 3: Functions and Generics. We type function parameters and results, overloads and this, then write generic functions, a typed fetch wrapper and a typed event emitter. Practise first: take your Product type and write function describe(p: Product): string. Pass it an inline literal with a typo and read the error.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Advanced TypeScript for Interviews: Generics, Narrowing, Utility and Conditional Types · Senior Frontend Depth
- Validation at the Edge: Schemas, DTOs and Trusting Nothing · Backend in Depth
- TypeScript Interview Questions · Full-Stack Questions
Part of the study guide
Related series