arthrop0d

Brief articles on varied topics.

TypeScript Structural Assignability and Excess Properties

typescriptsoftware engineering

TypeScript uses structural assignability: a value can be used where another type is expected when its structure contains the members the target requires, with compatible types. In other words, compatibility is based primarily on what an object has, not on the name of the type that produced it.

The problem this solves

In a nominal type system, two types are compatible only when they have an explicit relationship, such as inheritance or an implements declaration. TypeScript generally does not require that relationship for object types. This makes it practical to describe the shape required by a function without forcing every caller to create a specially named class or interface.

interface LogEntry {
  message: string;
  severity: "info" | "error";
}

function writeLog(entry: LogEntry) {
  console.log(`[${entry.severity}] ${entry.message}`);
}

const event = {
  message: "Cache refreshed",
  severity: "info" as const,
  timestamp: Date.now()
};

writeLog(event);

event was not declared as a LogEntry, but it has the two properties that writeLog needs, and their types are compatible. Its additional timestamp property does not prevent the call.

Without structural assignability, you would have to annotate event as LogEntry, construct it through a specific factory, or declare an explicit relationship between its type and LogEntry. That would be more restrictive than the function’s actual requirement: it only needs an object with two usable properties.

How the comparison works

When TypeScript checks whether a source value is assignable to a target type, it compares the members that the target demands.

type RequestOptions = {
  url: string;
  timeoutMs?: number;
};

const options = {
  url: "/health",
  timeoutMs: 5000,
  retries: 2
};

const request: RequestOptions = options;

RequestOptions requires url, and options.url is a string. timeoutMs is optional, so it may be present or absent; when present, it must be a number. options satisfies both conditions. retries is extra, but structural assignability normally permits extra properties on a value held in a variable.

The direction matters. The source must provide everything the target might use:

type HasId = { id: string };
type HasIdAndName = { id: string; name: string };

const full: HasIdAndName = { id: "a17", name: "Queue" };
const smaller: HasId = full; // Works

const onlyId: HasId = { id: "a17" };
const needsName: HasIdAndName = onlyId; // Error

A value with id and name can be used where only id is needed. A value with only id cannot safely be used where code may read name.

For property types, TypeScript also checks compatibility. A string is not assignable to a number, and a required property cannot be missing. Other rules apply to optional and readonly properties, but the central question remains: can the target’s operations be performed safely on the source value?

Why object literals behave differently

Consider the same shape written directly at the call site:

interface RequestOptions {
  url: string;
  timeoutMs?: number;
}

startRequest({
  url: "/health",
  timeoutMs: 5000,
  retries: 2
}); // Error: 'retries' is not an expected property

This is an excess-property check. An excess-property check is an additional check TypeScript applies to a new object literal when that literal is being matched directly against an object type. The literal is sometimes described as fresh: it has just been written in that context, rather than coming from an already-existing variable.

The check exists mainly to catch spelling mistakes and accidental configuration:

startRequest({
  url: "/health",
  timeuotMs: 5000
}); // Likely typo: 'timeuotMs'

If TypeScript accepted every unknown property on a fresh configuration object, this typo could quietly produce an object with the default timeout. Rejecting it gives an immediate error near the mistake.

This is deliberately stricter than ordinary structural assignability. Once the object has been stored in a variable, TypeScript usually treats it as an existing value and checks whether it contains the required structure, rather than rejecting every property the target does not mention:

const config = {
  url: "/health",
  timeuotMs: 5000
};

startRequest(config); // Structurally acceptable if `url` is all the target requires

That last call is not proof that the spelling is correct. It only reflects the fact that config has a url string and that extra properties are generally allowed for variables. The excess-property check is a typo-detection heuristic, not a general rule that makes object types exact.

A useful mental model

Separate two related decisions:

  1. Structural assignability: does the source have all the members the target requires, with compatible types?
  2. Excess-property checking: if the source is a fresh object literal, does it also contain an unexpected property that looks accidental?

The first governs normal compatibility throughout TypeScript. The second is a special safeguard around object literals. It does not remove extra properties at runtime, validate data received over HTTP, or make a type an exact schema. TypeScript’s checks disappear when the code is compiled, so runtime input still needs explicit validation when correctness or security depends on it.

When an error says an object literal “may only specify known properties,” you are seeing this second mechanism—not a contradiction of structural typing. TypeScript is accepting that objects can have more capabilities than a target needs, while asking you to confirm that a newly written literal did not contain an accidental property.

← All articles