TypeScript8 min read

Where TypeScript's Structural Typing Stops Being Obvious

How assignability, excess property checks, private fields, and satisfies interact at API boundaries.

  • type system
  • structural typing
  • API design

TypeScript is usually described as structurally typed: if a value has the required shape, it can be used regardless of where it was declared. That sentence is true often enough to be useful, but it hides several boundary rules that experienced developers encounter in configuration objects, class hierarchies, and generic APIs.

The key is to ask what operation the compiler is checking. Assigning a variable, checking a fresh literal, inferring a generic argument, and comparing classes do not all use precisely the same rule.

Assignability follows members, not names

Two independently declared object types are compatible when the source provides the properties required by the target with compatible types.

type Point = { x: number; y: number };
type Pixel = { x: number; y: number; color: string };

const pixel: Pixel = { x: 4, y: 8, color: 'lime' };
const point: Point = pixel;

Pixel has everything Point requires, so the assignment succeeds. The extra color property remains on the runtime object; assigning it to a narrower static type does not clone or trim it.

This is why interfaces from unrelated libraries can interoperate without explicitly extending one another. It is also why a rename or a small change in property optionality can alter compatibility across a wide part of a program.

Fresh literals receive a stricter check

TypeScript applies excess property checking whenever an object is assigned to a named object type. The check rejects properties the target does not mention, regardless of whether the source is an inline literal or an existing variable.

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

const options = {
  url: '/reports',
  timeout: 2_000,
  retries: 3,
};

const request: RequestOptions = options;

Here retries is reported as an excess property. Moving the object into a variable can improve error location and inference, but it does not change the assignability rule. An index signature such as [key: string]: unknown is the normal way to state that additional keys are accepted.

The intent is typo detection, not exact object types. TypeScript generally permits a value to carry more information than the consumer needs, so excess checks should be understood as an additional diagnostic around object construction rather than a runtime guarantee.

satisfies validates without widening away useful detail

The satisfies operator is valuable for configuration objects because it verifies a constraint while keeping a useful inferred type for the expression.

type Route = {
  path: `/${string}`;
  secure: boolean;
};

const routes = {
  account: { path: '/account', secure: true },
  help: { path: '/help', secure: false },
} satisfies Record<string, Route>;

Unlike a type annotation, satisfies changes the resulting type of routes to Record<string, Route>. That means callers can index it with any string, while the literal keys are retained only for editor completion. This combination is particularly useful when code needs both validation and an open-ended lookup table.

It is important to separate satisfies from a type assertion. value as Target tells the compiler to treat a value as the target type when the assertion is sufficiently plausible. value satisfies Target asks the compiler to prove compatibility and produces an error when it cannot.

Classes are structural—with an ancestry constraint

Classes participate in structural typing, but private and protected members add a nominal-looking rule. If a target type contains a private member, the source must contain a private member with the same name and compatible type.

class Session {
  private token = '';
}

class CachedSession {
  private token = '';
}

const session: Session = new CachedSession();

Because both classes declare a private string named token, the assignment is valid. The private field is not accessible to consumers, but it still contributes to compatibility. This prevents accidental mixing only when libraries choose distinct private member names.

ECMAScript #private fields are also runtime-enforced, unlike TypeScript’s private modifier, which is primarily checked at compile time. Either kind can make an otherwise identical class incompatible with another declaration.

Function parameters expose variance decisions

Structural compatibility also applies to functions, where input and output positions matter. A callback that accepts a broad input can safely stand in for one that will receive a narrower input:

type Animal = { name: string };
type Dog = Animal & { bark(): void };

const describeAnimal = (animal: Animal) => animal.name;
const describeDog: (dog: Dog) => string = describeAnimal;

The reverse is unsafe because a function requiring Dog might call bark, while its caller is entitled to pass any Animal. With strictFunctionTypes, TypeScript checks many function parameter positions contravariantly. Method syntax retains more permissive behaviour for compatibility with common class and interface patterns, which is one reason callbacks represented as properties can produce different results from methods.

Design for the boundary you actually own

Structural typing is most effective when public types describe capabilities rather than implementation identities. Accept the smallest shape a function needs and return types that preserve information useful to callers. Reach for private members or branding only when mixing structurally identical values would represent a real domain error.

Above all, do not infer runtime behaviour from an assignability result. The compiler does not remove extra keys, freeze readonly objects, or validate data arriving over the network. Structural typing is a model for checking programs; validation and object transformation remain separate runtime responsibilities.