Skip to Content
Course content

132: Common TypeScript Interview Questions on Structural Typing

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've ever come from a language like Java or C#, TypeScript's type system can feel like it's gaslighting you. You define two completely different interfaces, you're certain they are distinct entities, and then TypeScript tells you they are exactly the same thing. This is the core of structural typing, and it's a favorite topic for interviewers because it reveals whether you actually understand how TypeScript works under the hood or if you're just guessing.

Let's look at a snippet I saw in a code review recently. The developer was trying to ensure that a function only processed "Clean" configuration objects, but they were running into a weird inconsistency with how errors were being reported.

interface AppConfig {
  apiUrl: string;
  timeout: number;
}

function initializeApp(config: AppConfig) {
  console.log(`Connecting to ${config.apiUrl}...`);
}

// ❌ Error: Object literal may only specify known properties.
// 'retryAttempts' does not exist in type 'AppConfig'.
initializeApp({
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retryAttempts: 3 
});

const myConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retryAttempts: 3
};

// ✅ This works! No error.
initializeApp(myConfig);

The Excess Property Check Paradox

The developer was baffled. Why does the first call fail while the second one—passing the exact same data—succeeds? This is where most candidates trip up in interviews. They'll tell you the second one "should" be an error too.

Here is the deal: TypeScript is structurally typed. If an object has the required properties (the "shape"), it's compatible. myConfig has an apiUrl and a timeout. The fact that it also has retryAttempts doesn't matter; it satisfies the contract of AppConfig.

So why the error on the first call? That's a specific safety feature called Excess Property Checking. When you pass an object literal directly into a function, TypeScript assumes you're trying to be precise. If you add a property that isn't in the interface, you probably made a typo (e.g., writing apiURL instead of apiUrl). But once you assign that object to a variable, you're no longer passing a literal—you're passing a reference to an object that "fits the shape."

Shape Over Identity

In a nominal type system (like Java), if you have two classes with the exact same fields but different names, they are not interchangeable. In TypeScript, they are. I always tell my juniors to think of it as "Duck Typing" but with a compiler.

interface User {
  id: string;
  name: string;
}

interface Product {
  id: string;
  name: string;
}

const user: User = { id: "u1", name: "Alice" };
const product: Product = { id: "p1", name: "Laptop" };

// This is perfectly legal in TypeScript
const swap = (item: User) => console.log(item.name);

swap(product); // No error!

In an interview, you might be asked: "How do we prevent this?" Because in a real app, passing a Product into a User function is a recipe for a production outage. You can't just "make the interface different" because if the shapes are the same, TS sees them as the same.

Forcing Uniqueness with Branded Types

To get around structural typing when you actually need nominal typing, we use a technique called "Branding" or "Tagging." We add a property to the type that doesn't actually exist at runtime but exists for the compiler. It's essentially a lie we tell the type system to keep our data safe.

interface User {
  __brand: "User"; // The brand
  id: string;
  name: string;
}

interface Product {
  __brand: "Product"; // A different brand
  id: string;
  name: string;
}

const user = { id: "u1", name: "Alice" } as User;
const product = { id: "p1", name: "Laptop" } as Product;

const processUser = (u: User) => console.log(u.name);

processUser(user);    // ✅ Works
processUser(product); // ❌ Error: Property '__brand' is incompatible.

By adding __brand, the shapes are no longer identical. The Product shape doesn't have a __brand of "User", so the structural check fails. I use this constantly for IDs (e.g., distinguishing a UserId from an OrderId when both are just strings) to prevent passing the wrong ID into a database query.




📋 Practical Task

Exercise: Implementing Branded IDs for a Banking System

You are building a banking application. Currently, AccountId and TransactionId are both defined as string. This is dangerous because a developer could accidentally pass a Transaction ID into a function that expects an Account ID, leading to catastrophic data errors.

Your Task:

  • Create two "branded" types: AccountId and TransactionId. Both should be based on a string but have unique brand properties to make them structurally incompatible.
  • Create a function getAccountBalance(id: AccountId): number that returns a dummy balance.
  • Create a function getTransactionDetails(id: TransactionId): string that returns a dummy detail string.
  • Implement a "type guard" or a casting mechanism (using as) to create a valid AccountId and TransactionId from raw strings.
  • Demonstrate that passing a TransactionId into getAccountBalance now triggers a TypeScript compiler error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.