Skip to content

Allow structural typing of prototypes #63314

@5cover

Description

@5cover

🔍 Search Terms

prototype typing
prototype chain types
prototype inheritance
Object.create typing
inherited properties typing

✅ Viability Checklist

⭐ Suggestion

Note

I haven't been able to find a proposal that generalized problems encountered by typing prototypes structurally (searching is:issue in:title prototypes). Sorry if this is a duplicate.

This proposal introduces structural prototypes to TypeScript: a way to model the prototype of a value or type within the existing structural type system instead of treating prototype lookup as invisible to typing.

Core idea:

  • every type has a prototype type
  • prototype information composes structurally
  • property lookup may include properties inherited through the prototype chain
  • ownership and inheritance remain distinct in the type system

This feature is intended to improve typing of APIs and patterns that rely on prototypes, including Object.getPrototypeOf, Object.setPrototypeOf, Object.create, inherited members, and reflection utilities.

It does not attempt to add nominal typing, exact typing, or identity-based reasoning. It keeps prototypes structural.

Motivation

JavaScript uses prototype inheritance at runtime, but TypeScript mostly types objects as if only their own declared properties existed. This creates a gap:

  • values can expose inherited properties that are visible and usable at runtime
  • Object.getPrototypeOf and related APIs cannot be described precisely, currently relying on extensive any usage.
  • reflected information about ownership versus inheritance is mostly unavailable to the type system
  • common operations such as reading inherited properties, walking prototypes, or setting prototypes are either under-typed or modeled loosely

Today TypeScript effectively flattens object behavior for most purposes while simultaneously pretending prototype structure is not part of the model. This proposal replaces that omission with an explicit structural model.

The intent is not to make prototypes nominal or identity-sensitive. The intent is to say: if JavaScript exposes prototype lookup structurally at runtime, TypeScript should be able to expose it structurally at compile time.

Goals

  • Model prototypes under current structural typing principles.
  • Make the type of Object.getPrototypeOf(x) expressible.
  • Preserve the distinction between own and inherited properties.
  • Allow inherited properties to participate in ordinary property reads.
  • Support more accurate typing for prototype-manipulating APIs.
  • Keep the model compositional under unions, intersections, and recursion.

Non-goals

  • Nominal typing.
  • Exact ownership tracking.
  • Encoding prototype identity.
  • Proving mutation stability of shared prototypes.
  • Replacing or redefining JavaScript runtime behavior.

Proposed syntax

Two new type operators are introduced.

Unary prototype query

prototypeof T

This gives the prototype type associated with T.

Infix prototype attachment

T prototype P

This produces a type with the same own properties as T and prototype type P.

This operator is right-associative:

T prototype P prototype Q

means:

T prototype (P prototype Q)

not:

(T prototype P) prototype Q

This matches JavaScript prototype chains.

Core model

Every type T has two components:

  • its own structure, written normally as T
  • its prototype structure, written prototypeof T

These are both structural.

The type system distinguishes between:

  • properties declared directly on T
  • properties reachable through the prototype chain

The proposal introduces an implicit notion of apparent structure for property lookup.

Apparent structure

Property reads in JavaScript walk the prototype chain. To model that, define an apparent form:

type Apparent<T> = T & Apparent<Readonly<Omit<prototypeof T, keyof T>>>

with recursive expansion continuing until no new keys are introduced (e.g. keyof prototypeof T extends never).

Note

Apparent<T> and other helper types are used in this proposal. This proposal is not suggesting their integration as utility types.

  • own properties are visible first
  • prototype properties are visible if not shadowed by own properties
  • inherited properties are treated as readonly views (see "Readonly inherited properties")
  • deeper prototypes contribute recursively

This is not a source-level utility type. It is a specification device describing lookup semantics.

Termination

The expansion stops when the next prototype layer contributes no new keys.

This fixed-point behavior handles:

  • finite prototype chains
  • self-reference such as T prototype T
  • indirect cycles
  • recursive prototype types

Defaults

Object types

For a plain object type declaration such as:

type Config = { port: number }

the default prototype is:

prototypeof Config = object | null

This means the type guarantees nothing about its prototype unless specified.

This is a deliberate abstraction boundary for object types.

object | null represents the "top type" of prototypes. All prototypes are be assignable to it.

A type T prototype P is only valid if P extends (is assignable to) object | null.

This means

type Proto<T,P> = T prototype P // invalid
type Proto<T,P extends object | null> = T prototype P // correct

Inferred values and literals

For values whose construction is known, the compiler may infer a more precise prototype.

For example:

const a = {}

may be inferred as:

{} prototype Object

(assuming Object = typeof Object.prototype, which is the case as of 5.9.3)

Likewise, other literals or known constructors may infer concrete built-in prototypes.

This separates: declared type-level abstraction from value-level inference of known constructors.

Prototype types

never

prototypeof T being never is appropriate for values such as null and undefined, for which Object.getPrototypeOf throws.

null

null is a valid prototype value in JavaScript representing the absence of a prototype. A type may have prototypeof T = null, representing an object created with Object.create(null), Object.getPrototypeOf(Object.getPrototypeOf({})), or equivalent.

object

object represents a prototype nothing is known about besides its existence.

Primitive types

For primitive types, primitive values may infer prototypes when appropriate. For instance, prototypeof number is Number.

Semantics of key operations

Property access

A property access t.x type-checks if x exists in Apparent<T>.

Indexed access

Indexed access walks prototypes implicitly.

T[K]

is resolved against Apparent<T>, preserving shadowing and readonly inherited members.

This ensures consistency between value-level reads and type-level indexed access:

  • if t.x is readable, then T["x"] is meaningful
  • inherited properties contribute to indexed access types

keyof

keyof remains own-only.

keyof T

returns only keys declared directly on T, not inherited keys.

Rationale:

  • preserves locality
  • avoids far-away prototype changes affecting unrelated keyofs
  • keeps mapped types predictable
  • makes prototype-aware traversal explicit rather than implicit

Prototype-aware key traversal can be expressed separately.

Mapped types

Mapped types operate on own keys only because they are driven by keyof.

This means mapped types do not implicitly preserve prototype structure unless explicitly reattached.

Assignability

Assignability distinguishes between readable and writable requirements.

  • Own properties satisfy both read and write requirements.
  • Inherited properties (from the prototype chain) are treated as readonly:
    • they can satisfy readonly property requirements
    • they cannot satisfy mutable (writable) property requirements

This reflects JavaScript behavior:

  • reading walks the prototype chain
  • writing to an inherited property creates a new own property instead of mutating the prototype

Example

declare function f(x: { readonly a: string; b: number }): void

const x = Object.assign(Object.create({ a: "hi" }), { b: 440 })
f(x) // valid

This is valid because:

  • a is available through the prototype chain and only required as readonly
  • b is an own property

Invalid case

declare function f(x: { a: string; b: number }): void

const x = Object.assign(Object.create({ a: "hi" }), { b: 440 })
f(x) // error

This is rejected because:

  • a is inherited
  • the target type requires it to be writable
  • writing would create a new own property, which is not guaranteed by the source type

Readonly inherited properties

A property reached through the prototype chain is treated as readonly unless it is already an own property of the base type.

This is captured in the apparent form by applying Readonly to prototype contributions.

Rationale:

  • reading inherited properties is normal JavaScript behavior
  • writing through an inherited property often creates an own shadowing property instead of mutating the prototype
  • making inherited members readonly avoids accidentally treating them as ordinary writable local properties

Example:

type T = {} prototype { a: number }

Then T["a"] behaves as a readonly property view.

This is a type-system safety rule. It does not claim that JavaScript runtime assignment is impossible; it prevents accidental confusion in the type model.

Shadowing

Own properties shadow inherited properties by name.

Example:

type T = { a: string } prototype { a: number; b: boolean }

Then:

  • T["a"] is string
  • T["b"] is readonly boolean

The own declaration wins.

Union and intersection behavior

Prototype information remains structural.

Unions

type U = (A prototype P) | (B prototype Q)

Then:

prototypeof U = P | Q

and property lookup behaves as it normally does for unions.

Intersections

type I = (A prototype P) & (B prototype Q)

This refines both own structure and prototype structure under normal structural rules.

The intended reading is not "multiple runtime prototypes", but "a type whose apparent contract satisfies both sides structurally".

Recursive and cyclic prototypes

This proposal allows recursive prototype types.

Examples:

type Crazy<T> = T prototype Crazy<T>

and even degenerate forms such as:

T prototype T

These are valid if they stabilize under fixed-point apparent lookup.

For example, Crazy<T> contributes no keys beyond those already in T, so its apparent structure stabilizes to T.

Likewise, indirect prototype cycles are tolerated as long as no new properties are introduced indefinitely.

The checker use the same kinds of cycle detection, caching, and recursion-limiting techniques already used for recursive types.

Standard library typing examples

Object.getPrototypeOf

declare function getPrototypeOf<T extends {}>(obj: T): prototypeof T

Reflect.getPrototypeOf

declare function getPrototypeOf<T extends object>(target: T): prototypeof T

Object.setPrototypeOf

declare function setPrototypeOf<T extends {}, P extends object | null>(obj: T, proto: P): T prototype P

setPrototypeOf could also return asserts obj is T prototype P because it mutates obj.

Passing a non-object proto or obj doesn't throw (unlike stricter Reflect.setPrototypeOf) but it is a noop.

Reflect.setPrototypeOf

declare function setPrototypeOf<T extends object, P extends object | null>(obj: T, proto: P): T prototype P

Object.create

declare function create<P extends object | null>(proto: P): object prototype P

Additional overloads may model property descriptors and own members.

Examples

Basic attachment

type Parent = { host: string }
type Child = { port: number } prototype Parent

Then:

  • keyof Child is "port"
  • prototypeof Child is Parent
  • Child["host"] is readonly string
  • Child["port"] is number

Shadowing

type T = { a: string } prototype { a: number; b: boolean }

Then:

  • T["a"] is string
  • T["b"] is readonly boolean

Value inference

const x = Object.assign(Object.create({ a: 1 }), { b: 2 })

A possible inferred type is:

{ b: number } prototype { a: number }

Prototype-aware assignability

declare function takesAB(x: { a: string; b: number }): void

const x = Object.assign(Object.create({ a: "hi" }), { b: 440 })
takesAB(x) // valid

Prototype-aware readonly

type T = {} prototype { a: number }
declare let t: T

t.a        // ok
t.a = 1    // error under inherited-readonly rule

Rationale for own-only keyof

A major design choice is keeping keyof local.

If keyof walked prototype chains:

  • it would no longer be a local operator
  • mapped types would become prototype-sensitive by default
  • nested prototype changes could invalidate distant code unexpectedly
  • implementation and mental model complexity would increase significantly

By keeping keyof own-only:

  • local structure remains simple
  • prototype lookup is explicit where it matters
  • mapped types remain predictable
  • the only implicit prototype-walking operation is indexed access

This is a deliberate balance between fidelity and simplicity.

Soundness considerations

This proposal does not attempt to solve all mutation unsoundness in JavaScript.

In particular:

  • shared mutable prototypes can still change behind the type system's back
  • prototype writes from external aliases remain possible
  • runtime assignment to an inherited property may create an own property shadow instead of mutating the prototype

These are already part of JavaScript's behavior and align with existing TypeScript tradeoffs around mutability and aliasing.

Structural prototypes do not create a new category of unsoundness; they expose an existing runtime mechanism in a structured way.

The inherited-readonly rule reduces one common source of confusion without claiming full immutability.

Why structural rather than nominal

A nominal design would attempt to preserve prototype identity or class lineage. That is not the direction of this proposal.

TypeScript already centers structural compatibility. Prototypes are therefore be modeled the same way:

  • prototype information is a structural contract
  • unions and intersections compose structurally
  • apparent property lookup is structural
  • no runtime identity assumptions are introduced

The proposal is intentionally minimal in this respect.

Alternatives considered

Ignore prototypes entirely

This is the status quo. It leaves reflection and prototype APIs under-typed and forces the checker to overlook a real source of properties.

Make keyof walk prototypes

Rejected because it spreads prototype sensitivity too broadly and makes many local operations non-local.

Preserve writable inherited properties

Rejected because it allows accidental, implicit property creation from mutation of prototype-origin members through flattened derived object aliases.

Model prototypes nominally

Rejected because it conflicts with TypeScript's structural design and greatly increases complexity.

Backwards compatibility

This proposal is largely additive.

Potential impacts include:

  • more precise typing of inherited reads
  • new readonly errors for inherited-only properties if value inference becomes prototype-aware
  • improved typings for standard library reflection APIs

This design favors conservative defaults for declared object types to minimize disruption.

Related issues (non exhaustive)

This proposal overlaps with several existing TypeScript issues, but is not identical to any one of them. Most existing discussions focus on one symptom at a time: typing specific prototype APIs, inherited property behavior, or gaps between JavaScript's runtime object model and TypeScript's structural model.

Open questions

  • How precisely should value inference attach concrete built-in prototypes? It is possible inferring too eagerly could break existing code, such behavior could be walled behind a new flag strictPrototypeInference.
  • How should index signatures interact with inherited readonly lookup in detail?
  • Which standard library APIs should be updated initially?
  • Should there be syntax for prototype-aware key extraction in the standard library, or should such helpers remain user-defined?

Conclusion

Structural prototypes extend TypeScript's existing structural model to a part of JavaScript it currently mostly ignores.

The key idea is:

  • keep prototypes structural
  • keep ownership distinct from inheritance
  • allow property lookup to reflect runtime prototype reads
  • preserve locality for most existing type operators

This approach improves precision for prototype-heavy code without forcing TypeScript into nominal or exact typing.

It does not reinvent JavaScript's object model. It simply stops pretending prototypes are absent.

📃 Motivating Example

Playground link

// A prototype-oriented helper library written in ordinary JavaScript style.

// A prototype object with shared behavior
const nodeProto = {
    kind: 'node',
    id: -1,

    describe(this: { id: string }) {
        return `node:${this.id}`;
    },
};
type NodeProto = typeof nodeProto;

// "pseudo prototype" workaround for thi example
/**/
type Apparent<T> = T & Readonly<Omit<NodeProto, keyof T>>;
/*/
type Apparent<T> = T & Apparent<Readonly<Omit<prototypeof T, keyof T>>>
/*/
// Create a value with own data and shared prototype behavior
type UNode = {
    id: string;
    name: string;
};

/*
Today, TypeScript more or less loses the prototype information here.
A structural-prototype model could infer:

const user: UNode prototype UProto
*/
const user: Apparent<UNode> = Object.assign(Object.create(nodeProto), {
    id: 'u1',
    name: 'Ada',
} satisfies UNode);

// Inherited reads would be typed naturally
type C1 = Test<Same<typeof user.id, string>>; // own, shadows prototype
type C2 = Test<Same<typeof user.name, string>>; // own
type C3 = Test<Same<typeof user.kind, string>>; // readonly (inherited)
type C4 = Test<Same<typeof user.describe, (this: { id: string }) => string>>; // inherited method

// But inherited members are readonly views, which matches JS better:
// @ts-expect-error
user.kind = 'other';
// @ts-expect-error
user.describe = () => '';

// own keys stay local and predictable
type UserKeys = keyof typeof user;
// "id" | "name"

// while indexed access can still reflect inherited readable members
type C5 = Test<Same<(typeof user)['kind'], typeof user.kind>>;
type C6 = Test<Same<(typeof user)['describe'], typeof user.describe>>;

// Reflection APIs become precisely typed
/**/
type Proto = NodeProto;
/*/
type Proto = prototypeof typeof user;
/*/

const proto: Proto = Object.getPrototypeOf(user);
type C7 = Test<Same<typeof proto.kind, string>>;
type C8 = Test<Same<typeof proto.describe, (this: { id: string }) => string>>;

// Ownership-sensitive utilities become expressible

declare function hasOwn<T, K extends PropertyKey>(obj: T, key: K): K extends keyof T ? true : boolean;
// keyof only gets provably own keys. to retrieve keys from the prototype chain too, use keyof Apparent<T>

const c9 = hasOwn(user, 'name');
type C9 = Test<Same<typeof c9, true>>; // own

// boolean for this value at runtime, because we don't know
// knowing would require exact types, which is out of scope for this proposal
const c10 = hasOwn(user, 'kind');
type C10 = Test<Same<typeof c10, boolean>>;

// Assignability stays structural but respects writable vs inherited
declare function takesReadableNode(x: { readonly kind: string; id: string }): void;

takesReadableNode(user); // ok

declare function takesWritableNode(x: { kind: string; id: string }): void;

takesWritableNode(user); // error: kind is inherited, not own-writable

type Test<T extends true> = T;
type Same<Actual, Expected> = [Actual] extends [Expected] ? ([Expected] extends [Actual] ? true : false) : false;

💻 Use Cases

  1. What do you want to use this for?*
    I want to use this to type ordinary JavaScript code that builds objects through prototypes rather than classes, especially patterns involving Object.create, Object.assign, Object.getPrototypeOf, inherited property reads, and ownership-sensitive checks like hasOwn. The goal is to let TypeScript describe these values structurally, including both their own properties and the contract of their prototype, without introducing nominal classes or identity-based reasoning.

  2. What shortcomings exist with current approaches?
    Current TypeScript models these patterns weakly because prototype relationships are mostly invisible to the type system. Objects created through Object.create may expose inherited properties and methods at runtime, but TypeScript cannot describe that structure precisely. As a result, APIs like Object.getPrototypeOf and prototype-based object construction lose useful type information, inherited property reads are under-modeled, and the distinction between own and inherited properties is largely unavailable statically. TypeScript is strong at typing object literals and class instances, but much weaker at typing prototype-built objects even though JavaScript exposes them structurally.

  3. What workarounds are you using in the meantime?
    The current workaround is to manually flatten prototype information into ordinary object types, duplicate inherited members onto the apparent object shape, or fall back to loose typings such as any, broad object types, assertions, or custom helper types. In practice this means either losing precision or writing types that do not reflect the actual runtime structure, especially for Object.create, Object.getPrototypeOf, and inherited members.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions