Structural typing
Learn why TypeScript compares object shapes, not type names: any object with the right properties fits, extras are fine, and two names can mean one type.
- 01Predict when an object fitsCheck a value against a type property by property: every property the type lists must be there, with a type that fits.
- 02Read assignability errorsExplain messages like
Property 'price' is missingandTypes of property 'price' are incompatiblein plain words. - 03Spot where names don't matterExplain why two types with the same shape are interchangeable, and keep look-alike values such as IDs apart.
An object fits if it has the right properties
In JavaScript, a function that reads user.name works with any object that has a name. It doesn't care what else the object holds, or where it came from:
function greet(user) {
return `Hi, ${user.name}`;
}
console.log(greet({ name: "Asha", email: "asha@example.com" }));
console.log(greet({ name: "Ravi" }));Lines 1 to 3 make a greet function that reads user.name. Line 5 passes an object with a name and an email, and prints Hi, Asha. Line 6 passes an object with only a name, and prints Hi, Ravi. Both work, because both objects have a name (Object basics covers objects and properties).
TypeScript checks this the same way. Here is the function with a type for its parameter:
type User = { name: string }; function greet(user: User) { return `Hi, ${user.name}`;} const asha = { name: "Asha", email: "asha@example.com", cartItems: 2 };// ^?console.log(greet(asha));Line 1 is a type alias: type User = … gives the name User to an object type, a list of the properties an object must have and the type of each (the Object types lesson covers writing them). Here the list has one entry, a name that is a string (text). Line 3 says greet takes a User.
Line 7 makes asha without saying it's a User. The query on line 8 shows the type TypeScript worked out from the value: an object with a name, an email, and a number of cartItems. Line 9 passes it to greet. There are no errors, and it prints Hi, Asha.
TypeScript never asked whether asha was declared as a User. It asked one question: does asha have a name that is text? It does, so it fits. The extra email and cartItems don't matter.
User lists: a name that is text. asha has one, so it fits. Its other two properties don't matter.This way of checking is called structural typing. The "structure", or shape, of a type is the list of its property names and their types. TypeScript compares shapes, not names. You may also hear it called duck typing, from the saying "if it walks like a duck and quacks like a duck, it's a duck".
A shop needs someone to deliver parcels. The advert asks for two things: a driving licence and a phone. A student, a teacher, or a retired chef can all apply. The shop checks the licence and the phone, and nothing else.
- In real life: The job asks for a driving licence and a phone
- In TypeScript: The type lists
nameandprice - In real life: Anyone who has both can take the job
- In TypeScript: Any object with both properties fits
- In real life: Being a good cook as well doesn't hurt
- In TypeScript: Extra properties are fine
- In real life: Nobody asks what your last job title was
- In TypeScript: TypeScript doesn't compare type names
TypeScript compares shapes, not names. A value fits a type when it has every property the type lists, each with a type that fits. It doesn't matter what the value's type is called, how it was made, or what extra properties it has.
Shapes, not names
A type's name is only a label for its shape. So two types with different names and the same properties are the same type to TypeScript:
type Customer = { name: string; email: string };type Subscriber = { name: string; email: string }; function sendNewsletter(subscriber: Subscriber) { return `Sending news to ${subscriber.email}`;} const asha: Customer = { name: "Asha", email: "asha@example.com" };console.log(sendNewsletter(asha));Lines 1 and 2 make two types, Customer and Subscriber, each with a name and an email. Line 4 says sendNewsletter takes a Subscriber. Line 8 makes asha a Customer. Line 9 passes the customer where a subscriber goes. There are no errors, and it prints Sending news to asha@example.com.
Customer and Subscriber are two names for the same shape. TypeScript compares the shape, so a Customer fits wherever a Subscriber goes.An interface is another way to name an object type. You write interface Customer { … } with the properties inside, instead of type Customer = { … }. The Type aliases & interfaces lesson compares the two. For this lesson, the point is that TypeScript compares interfaces by shape too:
interface Customer { name: string; email: string;} type Contact = { name: string; email: string }; const asha: Customer = { name: "Asha", email: "asha@example.com" };const contact: Contact = asha;// ^?console.log(contact.email);Lines 1 to 4 make an interface called Customer. Line 6 makes a type alias called Contact with the same two properties. Line 8 makes a Customer, and line 9 stores it in a Contact variable. The query on line 10 shows const contact: Contact, there are no errors, and line 11 prints asha@example.com.
When one type fits another
When a value fits a type, TypeScript says it is assignable to that type: allowed to be stored in a variable of that type, or passed to a parameter of that type. To decide, TypeScript goes through the type's list, one property at a time:
- Does the value have a property with this name? If not, the property is missing.
- Does that property's type fit the type the list says? If not, the two are incompatible.
- Once every property on the list is checked, anything else on the value is ignored.
Try it. The code below has a CartItem type with a name and a price, and an addToCart function that takes one. Type the inside of the cake object on line 7, and watch what TypeScript says about the call on line 8:
type CartItem = { name: string; price: number }; function addToCart(item: CartItem) { console.log(`Added ${item.name} for $${item.price}`);} const cake = { name: "Cake", price: 4 };addToCart(cake);{ name: "Cake", price: 4 }No errorsAdded Cake for $4The object has a name that is text and a price that is a number, which is everything CartItem lists. No errors, and the cart says Added Cake for $4.
Each suggestion tests one step of the list. Remove the price, and the call gets an error. The message has two lines. The first, Argument of type '{ name: string; }' is not assignable to parameter of type 'CartItem', means "this value doesn't fit the parameter". The second line says why: Property 'price' is missing. With price: "4", the second line becomes Types of property 'price' are incompatible: the price is there, but its type is wrong. Renaming name to title makes name missing, because names have to match exactly. An extra inStock, or the same properties in a different order, change nothing.
The table below sums up the tries. Read each row as: this cake object, does it fit CartItem, and why.
| The object | Does it fit? | Why |
|---|---|---|
{ name: "Cake", price: 4 } | Yes. | It has every property CartItem lists, each with the right type. |
{ price: 4, name: "Cake" } | Yes. | The order of properties doesn't matter. TypeScript looks each one up by name. |
{ name: "Cake", price: 4, inStock: true } | Yes, from a variable. | Extra properties don't matter. Only what CartItem lists is checked. |
{ name: "Cake" } | No. | The price that CartItem needs is missing. |
{ name: "Cake", price: "4" } | No. | The price is there, but it's text, and CartItem says a number. |
{ title: "Cake", price: 4 } | No. | title is a different name, so name is missing. |
The check has a direction. A Product with a name, a price, and a stock count fits where a CartItem goes, because it has everything a cart item needs. A CartItem doesn't fit where a Product goes, because it has no stock. Press each button to change line 7, and compare what the type needs with what the value has:
type Product = { name: string; price: number; stock: number };type CartItem = { name: string; price: number }; const tea: Product = { name: "Tea", price: 3, stock: 9 };const cake: CartItem = { name: "Cake", price: 4 }; const item: CartItem = tea;No errorsname, pricename, price, stockCartItem needs a name and a price. tea has both, plus a stock that CartItem doesn't mention. Extra properties don't matter, so it fits.
tea into CartItem and tea into { name: string } fit: the type asks for less than tea has. cake into Product gets Property 'stock' is missing in type 'CartItem' but required in type 'Product'. tea into { price: string } gets Types of property 'price' are incompatible, because the price is a number and the type wants text. tea into { name: string } also shows that a type doesn't need a name at all: an object type written in place is compared the same way.
tea has everything CartItem needs, plus a stock, so it fits. Bottom: Product needs a stock that cake doesn't have, so line 7 gets an error.Extra properties are fine
Extra properties being fine is what makes structural typing useful in frontend code. Data from a server usually has more fields than any one function needs:
type AvatarUser = { name: string; avatarUrl: string }; function avatarAlt(user: AvatarUser) { return `Photo of ${user.name}`;} const reply = { id: 42, name: "Asha", email: "asha@example.com", avatarUrl: "/avatars/asha.png", joined: "2024-03-01",};console.log(avatarAlt(reply));Line 1 says an AvatarUser has a name and an avatar address. Line 3 says avatarAlt takes one. Lines 7 to 13 are a user as a server might send it, with five fields. Line 14 passes the whole reply. It has the two fields AvatarUser needs, so there are no errors, and it prints Photo of Asha. You don't have to copy the two fields into a new object first.
The check goes inside nested objects too, with the same rule at every level:
type Order = { id: number; customer: { name: string } }; const reply = { id: 7, customer: { name: "Asha", email: "asha@example.com" }, total: 12 };const order: Order = reply;// ^?console.log(order.customer.name);Line 1 says an Order has an id and a customer, and the customer has a name. Line 3's reply has an extra total, and its customer has an extra email. Line 4 stores it as an Order, and the query on line 5 shows const order: Order. Both levels have what the type needs, so there are no errors, and line 6 prints Asha.
The type doesn't remove the extras. They're still on the object when the code runs. The type only decides what you may read through that name:
type Contact = { name: string }; function showContact(contact: Contact) { console.log(Object.keys(contact).join(", ")); console.log(contact.email);} const asha = { name: "Asha", email: "asha@example.com" };showContact(asha);Line 1 says a Contact has only a name. Line 9 passes asha, who also has an email. Inside the function, line 4 lists the object's real property names with Object.keys and prints name, email: the email is there. Line 5 reads contact.email, and TypeScript marks it, on purpose: Property 'email' does not exist on type 'Contact'. That means "the type you gave this parameter doesn't list an email, so I can't promise there is one". Run it anyway, and line 5 prints asha@example.com.
There is one place where extra properties do get an error: an object you write right where the type is expected, like inside the call itself:
type User = { name: string }; function greet(user: User) { return `Hi, ${user.name}`;} console.log(greet({ name: "Asha", email: "asha@example.com" }));This is the overview's greet, but line 7 writes the object inside the call instead of passing a variable. TypeScript marks the email, on purpose: Object literal may only specify known properties, and 'email' does not exist in type 'User'. An object literal is an object written out with braces, like { name: "Asha" }. When it goes straight into a typed spot, nothing else can ever read its extra properties, so TypeScript guesses the extra is a mistake, often a typo. The code still prints Hi, Asha. The next lesson, Excess property checks, is all about this extra check.
Comparing with nominal typing
Many languages, like Java and C#, use nominal typing. "Nominal" means "by name": a value fits a type only if it was declared with that type's name, or with a type that says it extends it. TypeScript doesn't work that way, even for classes (Class basics covers JavaScript classes):
class Product { name: string; price: number; constructor(name: string, price: number) { this.name = name; this.price = price; }} function priceTag(product: Product) { return `${product.name}: $${product.price}`;} console.log(priceTag(new Product("Tea", 3)));console.log(priceTag({ name: "Cake", price: 4 }));Lines 1 to 9 make a Product class with a name and a price. Line 11 says priceTag takes a Product. Line 15 passes a real Product, made with new, and prints Tea: $3. Line 16 passes a plain object that was never made by the class. It has a name and a price, so it fits too, and prints Cake: $4. In Java, line 16 would be an error. (A class with private members is the exception; a later lesson, public, private, protected & #private, covers it. Classes in TypeScript covers the class syntax.)
The table below compares the two ways of checking. Read each row as a question, then each way's answer.
| Question | Nominal (Java, C#) | Structural (TypeScript) |
|---|---|---|
| What decides whether a value fits a type? | The type's name: the value must be declared as that type, or as one that says it extends it. | The shape: the value must have the properties the type lists, with types that fit. |
| Do two types with the same properties but different names match? | No. Different names are different types. | Yes. TypeScript sees one shape, so they're interchangeable. |
| Does a plain object fit a class type? | No. Only objects made by that class, or a subclass, fit. | Yes, if it has the class's properties and methods (classes with private members are the exception). |
| Who uses it? | Languages like Java and C#. | TypeScript, because JavaScript code passes plain objects around everywhere. |
TypeScript works this way because JavaScript code passes plain objects around everywhere: replies from servers, options for functions, props in components. Checking shapes describes that code as it is.
Structural typing has one cost. When two kinds of value have the same shape, TypeScript can't tell them apart. IDs are the usual example:
type UserId = string;type ProductId = string; function loadUser(id: UserId) { return `Loading user ${id}`;} const productId: ProductId = "p-7";console.log(loadUser(productId));Lines 1 and 2 make two type aliases, UserId and ProductId. Line 4 says loadUser takes a UserId. Line 8 makes a product ID, and line 9 passes it to loadUser by mistake. There are no errors, and it prints Loading user p-7. A type alias doesn't make a new type. Both names mean string, and a string goes where a string goes.
UserId and ProductId are both names for string. To TypeScript they're the same type, so a product ID goes where a user ID belongs without an error.To keep them apart, give them different shapes. One way is to wrap each ID in an object with a kind property that must hold one exact text:
type UserId = { kind: "user"; value: string };type ProductId = { kind: "product"; value: string }; function loadUser(id: UserId) { return `Loading user ${id.value}`;} const productId: ProductId = { kind: "product", value: "p-7" };console.log(loadUser(productId));In line 1, kind: "user" means the kind property must be exactly the text "user", not any string (the Literal types lesson covers this). Line 2 does the same with "product". Now the two shapes differ, and line 9 gets an error, on purpose: Types of property 'kind' are incompatible, and below it, Type '"product"' is not assignable to type '"user"'. The mix-up is caught before the code runs. A later lesson, Branded types, shows a way to keep IDs apart while they stay plain strings when the code runs.
One product card, two sources of data
A shop shows the same product card on the catalog page and in search results. The two lists come from different places, so their objects have different extra fields. With structural typing, one card function takes both:
type CardProduct = { name: string; price: number; imageUrl: string }; function productCard(product: CardProduct) { return `${product.name} ($${product.price}) ${product.imageUrl}`;} const fromCatalog = { id: 1, name: "Tea", price: 3, imageUrl: "/img/tea.png", stock: 9 };const fromSearch = { name: "Cake", price: 4, imageUrl: "/img/cake.png", score: 0.92 }; console.log(productCard(fromCatalog));console.log(productCard(fromSearch));Line 1 lists the three things a card shows: a name, a price, and an image address. Line 7 is a catalog product, with an id and a stock as well. Line 8 is a search result, with a score that says how well it matched. Lines 10 and 11 pass both. Each has the three properties a card needs, so there are no errors, and the code prints Tea ($3) /img/tea.png and Cake ($4) /img/cake.png.
Now say the search service stops sending images:
type CardProduct = { name: string; price: number; imageUrl: string }; function productCard(product: CardProduct) { return `${product.name} ($${product.price}) ${product.imageUrl}`;} const fromSearch = { name: "Cake", price: 4, score: 0.92 }; console.log(productCard(fromSearch));Line 7's search result has no imageUrl. TypeScript marks line 9, on purpose: Property 'imageUrl' is missing. Plain JavaScript would draw a card with the image address undefined, and the code prints Cake ($4) undefined. Listing only what a function uses, as CardProduct does, keeps it open to every object that has those properties, and still catches the ones that don't.
const tea = { name: "Tea", price: 3 }; const label: { name: string } = tea;const tea = { name: "Tea" }; const item: { name: string; price: number } = tea;const tea = { name: "Tea", price: 3 }; const item: { name: number } = tea;const reply = { id: 7, name: "Asha", email: "asha@example.com" }; const user: { name: string } = reply;const user: { name: string } = { name: "Asha", age: 30 };type Customer = { name: string }; const asha: Customer = { name: "Asha" }; const user: { name: string } = asha;type UserId = string; type ProductId = string; const p: ProductId = "p-7"; const u: UserId = p;const p: { kind: "product" } = { kind: "product" }; const u: { kind: "user" } = p;
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
- "An object has to be declared as a type to fit it." No.
ashawas never declared as aUser, and it fits, because it has aname. - "Extra properties are always an error." No. Extras on a variable are fine. Only an object literal written right where the type is expected gets the extra check, which the next lesson covers.
- "Two types with different names are different types." No.
CustomerandSubscriberhave the same shape, so they're interchangeable. - "A type alias makes a new type." No.
type UserId = stringis just another name forstring, so any string fits it, including a product ID. - "If A fits B, then B fits A." No. A
Productfits where aCartItemgoes, but aCartItemhas no stock, so it doesn't fit where aProductgoes. - "The type removes the extra properties." No. Types are gone when the code runs. The object keeps every property; the type only limits what you may read through that name.
- "Classes are compared by name, as in Java." No. A plain object with the same properties fits a class type, unless the class has
privatemembers.
Practice exercises
Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
Guess before you check: does TypeScript report an error for this code?
type Shopper = { name: string };
function welcome(shopper: Shopper) {
return `Welcome back, ${shopper.name}`;
}
const asha = { name: "Asha", cartItems: 3, vip: true };
console.log(welcome(asha));type Shopper = { name: string }; function welcome(shopper: Shopper) { return `Welcome back, ${shopper.name}`;} const asha = { name: "Asha", cartItems: 3, vip: true };console.log(welcome(asha)); // no error: asha has a nameNo error. Shopper lists one property, name: string, and asha has a name that is text. Her extra cartItems and vip don't matter, because asha is a variable. It prints Welcome back, Asha.
TypeScript will mark line 8. Which property is missing from home? Then add it.
type Address = { street: string; city: string; zip: string };
function shippingLabel(address: Address) {
return `${address.street}, ${address.city} ${address.zip}`;
}
const home = { street: "12 Park Road", city: "Pune" };
console.log(shippingLabel(home));type Address = { street: string; city: string; zip: string }; function shippingLabel(address: Address) { return `${address.street}, ${address.city} ${address.zip}`;} const home = { street: "12 Park Road", city: "Pune", zip: "411001" };console.log(shippingLabel(home));Address needs a zip, and home has none, so line 8 gets Property 'zip' is missing. With a zip added, the error is gone and it prints 12 Park Road, Pune 411001.
The server calls its fields title and cost. Don't change CartItem or the reply. Make the call fit. What does your fixed code print?
type CartItem = { name: string; price: number }; function lineText(item: CartItem) { return `${item.name}: $${item.price}`;} const reply = { title: "Tea", cost: 3 };console.log(lineText(reply));type CartItem = { name: string; price: number }; function lineText(item: CartItem) { return `${item.name}: $${item.price}`;} const reply = { title: "Tea", cost: 3 };const item = { name: reply.title, price: reply.cost };console.log(lineText(item));Names must match exactly, so title never counts as name. Line 8 builds an object with the names CartItem uses. It fits, and the code prints Tea: $3.
userTag and productTag do the same job. Replace them with one function, nameTag, that takes either. What must its parameter's type list?
type User = { name: string; email: string };type Product = { name: string; price: number }; function userTag(user: User) { return `[${user.name}]`;} function productTag(product: Product) { return `[${product.name}]`;} const asha: User = { name: "Asha", email: "asha@example.com" };const tea: Product = { name: "Tea", price: 3 };console.log(userTag(asha), productTag(tea));type User = { name: string; email: string };type Product = { name: string; price: number }; function nameTag(thing: { name: string }) { return `[${thing.name}]`;} const asha: User = { name: "Asha", email: "asha@example.com" };const tea: Product = { name: "Tea", price: 3 };console.log(nameTag(asha), nameTag(tea));Both functions only read name, so one function whose parameter type lists just name: string takes both. A User and a Product each have a name that is text. It still prints [Asha] [Tea].
TypeScript is happy with this code. What does it print? Then change the two ID types so TypeScript catches the mix-up on line 9.
type UserId = string;type ProductId = string; function removeFromWishlist(id: ProductId) { return `Removing product ${id}`;} const userId: UserId = "u-42";console.log(removeFromWishlist(userId));type UserId = { kind: "user"; value: string };type ProductId = { kind: "product"; value: string }; function removeFromWishlist(id: ProductId) { return `Removing product ${id.value}`;} const userId: UserId = { kind: "user", value: "u-42" };console.log(removeFromWishlist(userId));The starter prints Removing product u-42 with no error, because both IDs are strings. With a kind property, the shapes differ, and line 9 gets an error, which is what you want: Type '"user"' is not assignable to type '"product"'. Now pass a real product ID there.
One of lines 6 and 7 gets an error. Which line? Then fix it without changing the types. The ... spread syntax helps (Spread and rest covers it).
type Product = { name: string; price: number; stock: number };
type CartItem = { name: string; price: number };
const tea: Product = { name: "Tea", price: 3, stock: 9 };
const cake: CartItem = { name: "Cake", price: 4 };
const first: CartItem = tea;
const second: Product = cake;type Product = { name: string; price: number; stock: number };type CartItem = { name: string; price: number }; const tea: Product = { name: "Tea", price: 3, stock: 9 };const cake: CartItem = { name: "Cake", price: 4 };const first: CartItem = tea;const second: Product = { ...cake, stock: 0 };console.log(second.stock);Line 6 fits: tea has a name and a price, plus an extra stock. Line 7 doesn't: a Product needs a stock, and cake has none. The fix copies the cake's properties with spread syntax and adds a stock, so line 8 prints 0.
Check your understanding
For each question, go through the type's list one property at a time: is it there, and does its type fit? The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7When TypeScript checks whether an object fits a type, what does it compare?
Choose an answer to see the explanation.
Question 2 of 7Does this welcome call get an error, and what prints?
Read the code, then predicttype Shopper = { name: string }; function welcome(shopper: Shopper) { return `Welcome, ${shopper.name}`; } const ravi = { name: "Ravi", points: 120 }; console.log(welcome(ravi));Choose an answer to see the explanation.
Question 3 of 7Which line of this cart code does TypeScript mark?
Read the code, then predicttype Product = { name: string; price: number; stock: number }; type CartItem = { name: string; price: number }; const cake: CartItem = { name: "Cake", price: 4 }; const tea: Product = { name: "Tea", price: 3, stock: 9 }; const a: CartItem = tea; const b: Product = cake;Choose an answer to see the explanation.
Question 4 of 7What happens when a coupon ID goes where an order ID belongs?
Read the code, then predicttype OrderId = string; type CouponId = string; function cancelOrder(id: OrderId) { return `Cancelled ${id}`; } const coupon: CouponId = "SAVE10"; console.log(cancelOrder(coupon));Choose an answer to see the explanation.
Question 5 of 7What does countFields print for Asha's contact?
Read the code, then predicttype Contact = { name: string }; function countFields(contact: Contact) { return Object.keys(contact).length; } const asha = { name: "Asha", email: "asha@example.com", phone: "555-0101" }; console.log(countFields(asha));Choose an answer to see the explanation.
Question 6 of 7In a nominally typed language like Java, when does a value fit a type?
Choose an answer to see the explanation.
Question 7 of 7Does this greet call, with the object written inside it, get an error?
Read the code, then predicttype Contact = { name: string }; function greet(contact: Contact) { return `Hi, ${contact.name}`; } console.log(greet({ name: "Asha", phone: "555-0101" }));Choose an answer to see the explanation.
Key takeaways
- TypeScript compares shapes, not names. A value fits a type when it has every property the type lists, each with a type that fits.
- A value doesn't have to be declared as a type to fit it, and the order of properties doesn't matter.
- Extra properties on a variable are fine. The type only limits what you may read; the extras are still there when the code runs.
- The check has a direction: a bigger shape fits a smaller one, not the other way round.
- Two types with the same shape are interchangeable, and a type alias like
UserId = stringis just another name. - To keep look-alike values apart, give them different shapes, like a
kindproperty.
Remember the one-liner.
If it has the properties the type asks for, with the right types, it fits.
Coming next: Excess property checks. It explains why an object literal with an extra property gets an error while the same object in a variable doesn't, and how that catches typos.