TypeScript: Get in Shape with Structural Typing

Posted . Visible to the public.

TypeScript basically uses structural typing, which is conceptionally quite similar to duck typing, but with static compile-time type checking. We'll explore what this means in practice.

TypeScript is a superset of JavaScript, meaning TypeScript compiles down to native JavaScript syntax and checks type consistency only at compile time.

Idea of Structural Typing

TypeScript only wants to know, whether the shape of two objects are identical:

interface Point2D {
  x: number;
  y: number;
}

function printPoint(p: Point2D) {
  console.log(`(${p.x}, ${p.y})`);
}

const point3D = { x: 1, y: 2, z: 3 }; // has extra property z
printPoint(point3D); // [LOG]: "(1, 2)" 

TypeScript Code Playground Show archive.org snapshot

Now here are some cases, where TypeScript will actually help me to catch bugs due to it's static typing:

printPoint({ x: 1 }); // Error: Property 'y' is missing
printPoint({ x: 1, yy: 2 }); // Error: Object literal may only specify known properties
printPoint({ x: 1, y: "two" }); // Error: Type 'string' is not assignable to type 'number'

What is even one of the most powerful features, is that when you rename y to yPos in the interface, every line within your code still using y instead of yPos will be flagged and prevent you from running this code.

Excess Property Checks

However, there's a well known and controversially discussed caveat of TypeScript, known as "Excess Property Checks". It basically states that fresh object are checked more strictly, so that their exact shape must match.

This applies to both assigning a type object to a variable and passing it as an argument to a function.

interface Config {
  host: string;
  port: number;
}

// Error: Object literal may only specify known properties.
const config: Config = { host: "localhost", port: 3000, timeout: 5000 };

// Workaround 1: assign to an intermediate variable first (structural check only)
const rawConfig = { host: "localhost", port: 3000, timeout: 5000 };
const config: Config = rawConfig;

// Workaround 2: type assertion (you take responsibility)
const config2: Config = { host: "localhost", port: 3000, timeout: 5000 } as Config;

TypeScript Code Playground Show archive.org snapshot

Let's take a look at the same for functions:

interface User {
  name: string;
  age: number;
}

function greet(user: User) {
  console.log(`Hello ${user.name}`);
}

// Object literal passed directly as argument
// greet({ name: "Felix", age: 30, role: "admin" });
// Error: Argument of type '{ name: string; age: number; role: string; }'
//        is not assignable to parameter of type 'User'.
//        Object literal may only specify known properties.

// object via intermediate variable
const felix = { name: "Felix", age: 30, role: "admin" };
greet(felix); // [LOG]: "Hello Felix" 

// object returned from another function
function getUser() {
  return { name: "Felix", age: 30, role: "admin" };
}
greet(getUser()); // [LOG]: "Hello Felix" 

TypeScript Code Playground Show archive.org snapshot

But why is it controversial I hear you saying. Because it's violates the basic premise, that objects with the same shape are compatible. On the other side, the strict checks can bypassed easily by using an intermediate variable. Originally this feature was added to catch errors when passing arguments to a function.

That's why there's a long standing proposal of exact types Show archive.org snapshot .

Type Assertions

In the example above you could have been writing { ... } as User behind the newly created passed object and the code would have compiled perfectly fine!

greet({ name: "Felix", age: 30, role: "admin" } as User);  
// [LOG]: "Hello Felix" 

The as operator is a type assertion. You're saying, treat this variable as a different type, that I know better than would you can guess.

This can be useful, when you're retrieving a point from an external source, e.g. checking whether it is drawn within a coordinate system or not:

function getPoint(): Point2D | null {
  // We'll just stub this using randomness
  return Math.random() > 0.5 ? { x: 1, y: 2 } : null; 
}

Now, the compile checks will help us letting us know that the returned value can potentially be null and will not allow to compile the code:

const p1 = getPoint();
// printPoint(p1); // Error: Argument of type 'Point2D | null' is not assignable to type 'Point2D'

But with a type assertion, we can silence the error but you'll take on the risk:

const p2 = getPoint() as Point2D;
printPoint(p2); // compiles fine 50% of the time
// But crashes the other times: TypeError: Cannot read properties of null

You'll now have to conditionally check that the value actually is not null or handle the null case independently (using type narrowing).

const p3 = getPoint(); // Safely check for null first
if (p3 !== null) {
  printPoint(p3); // TypeScript narrows the type to Point2D inside the block
}

TypeScript Code Playground Show archive.org snapshot

It is pointless to use a type assertions in this case, but there will be times where you'll know more about the code than TypeScript:

const el = document.querySelector("input"); // Has type HtmlElement | null (effectively HtmlElement or null)
// console.log(el.value)

// You know it's an input, assert
const input = el as HTMLInputElement;
console.log(input.value); // now accessible

TypeScript Code Playground Show archive.org snapshot

To the Rescue: satisfies

In the example before we also could have use ! to say basically turn of checking and tell the compiler, we know that the point from getPoint can never be null. However, we have a cooler kid in town.

Going back to our user example:

interface User {
  id: number;
  name: string;
  email: string | null; // email can be null
}

// Type is widened to `User`
const user: User = {
  id: 1,
  name: "Felix",
  email: "felix@example.com",
};

Now, as we've already seen, this will not work:

user.email.toUpperCase(); // Error: email might be null

TypeScript can't tell because the type is string | null.

We can tell TypeScript that we know that the value is not null and check it strictly at the same time using satisfies:

const user = {
  id: 1,
  name: "Felix",
  email: "felix@example.com",
} satisfies User;

user.email.toUpperCase() // This works perfectly fine!

TypeScript Code Playground Show archive.org snapshot

Profile picture of Felix Eschey
Felix Eschey
License
Source code in this card is licensed under the MIT License.
Posted by Felix Eschey to makandra dev (2026-02-23 09:27)