TypeScript: Get in Shape with Structural Typing

Posted . Visible to the public.

TypeScript basically uses structural typing, which is conceptually 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 shapes 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 catch bugs due to its 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'

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, preventing you from running the 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" objects are checked more strictly, so their exact shape must match.

This applies to both assigning a typed 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 config1: 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 behavior 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? Because it violates the basic premise that objects with the same shape are compatible. On the other hand, strict checks can be 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 for exact types Show archive.org snapshot .

Type Assertions

In the example above, you could have written { ... } as User after the newly created 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; I know better than what you can guess."

This can be useful when you're retrieving a point from an external source. For example, 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; 
}

Union Types

Point2D | null is called a union type. It effectively says that you can only assign elements of either one or the other type. It's a union because the set of all assignable values is the set of all Point2D values and all null values.

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

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, though you 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 must now conditionally check that the value is actually 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 type assertions in this case, but there will be times where you'll know more about the code than TypeScript can infer:

const el = document.querySelector("input"); // Has type HTMLElement | null
// console.log(el.value) // Error: Object is possibly 'null'.

// 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

Type Inference

Inferring is just a fancy word for the fact that the compiler tracks returned types even when you don't specify them. Now, when you have a function like function add(n: number) { return n + 1 }, the compiler will know that the returned type will be a number from thereon and limits what you can do with that value.

To the Rescue: satisfies

In the example before, we also could have used ! (the non-null assertion operator) to essentially turn off checking and tell the compiler we know 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 or string
}

// 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 it's a string because the type was widened to string | null.

We can tell TypeScript that we know the value is a string while still checking it against the interface 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

Type Narrowing

Earlier, we saw:

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

Whenever you apply a check on a specific type, the compiler knows that from there on, anything you do within that condition can only involve that type. Conversely, you may have to narrow specific types before you can apply operations with narrower type constraints.

Let's look at another example:

type User = { name: string };
const userOrUsers: User | User[] = [{name: 'Amir'}];

// console.log(userOrUsers.length) // Error: Property 'length' does not exist on type 'User'.
if (Array.isArray(userOrUsers)) {
  console.log(userOrUsers.length); // Works!
}

Here, the compiler knows that within that conditional, the type can only be an array and therefore allows access to the length property.

If we have a function that accepts this union type, we need conditionals to safely work with both:

function nameOrLength(userOrUsers: User | User[]) {
  if (Array.isArray(userOrUsers)) {
    // Inside this block, userOrUsers is User[]
    return userOrUsers.length;
  } else {
    // Inside this block, userOrUsers is User
    return userOrUsers.name;
  }
}

TypeScript Code Playground Show archive.org snapshot

Profile picture of Felix Eschey
Felix Eschey
Last edit
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)