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.

Lesson 0222 min read#typescript#types#classes

On this page

  1. Words used in this lesson
  2. Object types
  3. Index signatures
  4. interface or type
  5. Structural typing
  6. Classes
  7. Enums or unions of literals
  8. as const
  9. satisfies
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

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 both A and B.
  • Declaration merging means two interface declarations with the same name in the same scope are joined into one.
  • Access modifier is public, private or protected on 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 const makes a value as narrow and read-only as possible. satisfies checks 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:

JavaScript
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) };
TEXT
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, because name is a normal property.
  • A readonly property can be set when the object is made, and never after (TS2540).
  • An optional property (email?) has the type string | undefined. You must check it before use (TS18048), the same narrowing as Lesson 1. A safe read is u.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":

JavaScript
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;
TEXT
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:

JavaScript
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" };
TEXT
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:

  1. Reading scores.zed gives number | undefined, not number. That is noUncheckedIndexedAccess from Lesson 0: the key might not be there. Without that option TypeScript would say number, and scores.zed.toFixed() would crash at runtime.
  2. Every value must match the index type, including the fixed properties. Config says that retries is a number, and it also says every other property is a number, so label: "x" fails. If you need a mix of types, make the index type a union: [key: string]: number | string.
  3. An index signature means the object can have any key. When the keys are a known set, use Record with a union of keys, and TypeScript makes sure none is missing:
JavaScript
const lookup: Record<"a" | "b", number> = { a: 1 };
TEXT
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:

JavaScript
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:

interfacetype alias
Describes an object or a class shapeYesYes
Can name a union, a tuple, a primitive or a function typeNoYes
Declared twice with the same nameMerges into oneError
Extends another typeextends& (intersection)
Can be used with mapped and conditional types (Lesson 4)No, only the object shapesYes

The merge difference

JavaScript
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 };
TEXT
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

JavaScript
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:

JavaScript
interface Cb { (n: number): string; label: string }const cb: Cb = Object.assign((n: number) => String(n), { label: "x" });console.log(cb(5), cb.label);
TEXT
5 x

Extending: extends and &

JavaScript
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" };
TEXT
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:

JavaScript
type A = { id: string };type B = { id: number };type AB = A & B;type IdOfAB = AB["id"];const probe: IdOfAB = "a";
TEXT
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.

JavaScript
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:

JavaScript
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:

JavaScript
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);
TEXT
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'?.
  • Options has 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. Otherwise draw(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.

JavaScript
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);
TEXT
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.
ModifierWho can use itReal at runtime?
public (the default)everyonen/a
protectedthe class and its subclassesNo, compile time only
privatethe class onlyNo, compile time only
#namethe class onlyYes, it is JavaScript's own private field
readonlycannot be assigned after the constructorNo, 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:

JavaScript
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));
TEXT
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.

JavaScript
class Point {  constructor(public x: number, public y: number) {}}

Under our tsconfig, this is an error:

TEXT
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

JavaScript
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; }}
TEXT
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:

JavaScript
class Box {  value: number;}
TEXT
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:

JavaScript
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)); }
TEXT
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:

JavaScript
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:

JavaScript
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);
TEXT
1 Admin high

An 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:

JavaScript
enum Role { Admin, User }const r: Role = 99;
TEXT
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:

JavaScript
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:

JavaScript
const Status2 = { Idle: "idle", Loading: "loading" } as const;type Status2 = (typeof Status2)[keyof typeof Status2];let s: Status2 = "loading";s = "other";
TEXT
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.)

EnumUnion of literalsas const object
Exists at runtimeYes (extra JavaScript)NoYes, a plain object
Works with Node type stripping and erasableSyntaxOnlyNoYesYes
Loop over the valuesYesNoYes
Message in an errorRolethe literal valuesthe 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.

JavaScript
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;
TEXT
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:

JavaScript
const DIRS = ["up", "down", "left"] as const;type Dir = (typeof DIRS)[number];const d: Dir = "up";console.log(DIRS.length, d, DIRS.includes("up"));
TEXT
3 up true

Dir 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:

JavaScript
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);
TEXT
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:

JavaScript
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>;
TEXT
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:

JavaScript
const asCast = { red: "#f00", green: [0, 255, 0], blue: 5 } as Record<Color, string | Rgb>;
TEXT
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:

JavaScript
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>;
TEXT
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

  1. Write type Product = { id: number; title: string; price: number; tags?: string[] }. Create a product without tags. What type does p.tags have, and what does p.tags.length say?
  2. Write interface Named { name: string } and function greet(x: Named). Call it with a variable that has an extra property, then with the same object written inline. What does each do?
  3. Define interface Animal { name: string } and interface Dog extends Animal { name: number }. What does tsc say? Then write the same with type and &. Where does the error show up?
  4. Write a class Counter with a private count = 0, a method inc(), and a getter value. Read counter.count from outside. What does tsc say? Then use (counter as any).count = 100 and print counter.value.
  5. Replace enum Level { Low, High } with a version that has a runtime object and a type, using as const.
  6. Write const sizes = { s: 1, m: 2 } satisfies Record<string, number>. What is the type of sizes.s? Compare with the annotation : Record<string, number>.

Answers.

  1. p.tags is string[] | undefined. p.tags.length gives TS18048: 'p.tags' is possibly 'undefined'. Use p.tags?.length ?? 0.
  2. 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.)
  3. With interface Dog extends Animal, the error is at the interface: TS2430: Interface 'Dog' incorrectly extends interface 'Animal'. With type Dog = Animal & { name: number } there is no error at the definition; name becomes never and the problem shows only when you use it.
  4. TS2341: Property 'count' is private and only accessible within class 'Counter'. After the as any write, counter.value prints 100, because private is erased.
  5. const Level = { Low: 0, High: 1 } as const; type Level = (typeof Level)[keyof typeof Level];
  6. With satisfies, sizes.s is number (I checked with the never trick), and the object keeps only its own keys s and m. With the annotation Record<string, number>, you can read any key such as sizes2.zzz, and its type is number | undefined with noUncheckedIndexedAccess. 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), readonly blocks assignment (compile time only).
  • Index signatures allow any key; reading gives | undefined with noUncheckedIndexedAccess. Use Record<Union, T> for a known set of keys.
  • interface merges and gives early errors on extends. type can 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, protected and readonly are erased; #field is really private. Parameter properties and enums need code generation, so erasableSyntaxOnly rejects them.
  • Prefer a union of literals to an enum. Use as const for fixed lists and objects, and satisfies to 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

Part of the study guide

Related series