Trait Views: exposing behavior in JavaScript without inheritance

Published: (January 14, 2026 at 01:06 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

Introduction

JavaScript gives us many ways to share behavior: inheritance, mixins, composition, interfaces (via TypeScript).

Yet in larger systems, all of them tend to fall short in one way or another.

  • Inheritance is rigid.
  • Mixins tend to leak state.
  • Passing raw objects exposes too much surface area.

What JavaScript lacks is a way to say:

“Give me a view of this object, exposing a specific capability, with default behavior — without changing the object itself.”

This article introduces Trait Views: a runtime pattern inspired by Rust traits, adapted to JavaScript’s object model.

This is not a language proposal, and not a replacement for existing patterns. It is an exploration of a missing abstraction.

The problem

Consider a simple situation: you have an object, and you want to treat it as observable.

  • Not by inheriting from an Observable base class.
  • Not by mixing methods into it.
  • Not by passing the object itself and trusting everyone to “do the right thing”.

You want to say:

“For this part of the system, this object should be seen only as observable.”

JavaScript does not give us a native way to express that.

Trait Views — the idea

A Trait View is a derived object that exposes a specific behavior of another object. It is:

  • not the original object
  • not a copy of it
  • not a mixin applied to it

It is a projection.

You don’t add a trait to an object.
You derive a view of that object.

A minimal example: Observable

class Observable {
  static from = trait(Observable)

  observe(): number {
    return 0
  }

  // Note this one is optional
  observeTwice?(): number {
    return this.observe() * 2
  }
}

The trait defines:

  • one core behavior: observe
  • one default behavior built on top of it: observeTwice

Now, a completely unrelated object:

class Sensor implements Observable {
  value = 21

  observe(): number {
    return this.value
  }
}

The implements Observable clause is important. Even though Observable is never extended, TypeScript still enforces that:

  • all required methods are present
  • method signatures are compatible
  • refactors remain type‑safe

This means Trait Views are not “duck typing in the dark”. They are structurally typed and checked at compile time.

const sensor = new Sensor()

Observable.from(sensor).observeTwice() // 42

What happened here is subtle but important.

  • Sensor remains unchanged.
  • No methods were copied onto it.
  • No inheritance was introduced.

Instead, Observable.from(sensor) created a strongly typed view of sensor that exposes observable behavior, including default logic that the original object never had. The original object is not observable; the view is.

Stateless vs Stateful Trait Views

Trait Views can exist in two modes.

Stateless views Observable.from(...) → Stateless

A stateless trait view:

  • does not own any state
  • its own constructor is intentionally unused and never called
  • is cached per instance and is frozen for stability
  • delegates all behavior back to the original object

From a TypeScript perspective:

  • all trait methods are guaranteed to exist
  • trait‑owned properties remain optional

In stateless mode, trait‑owned properties are intentionally typed as optional. This anticipates cases where an implementing object may expose a getter with the same name, in which case materializing trait state would be incorrect.

Conceptually, this is close to a borrowed trait object in Rust (&dyn Trait): a stable interface over existing state.

Stateful views Observable.from(...) → Stateful

A stateful trait view:

  • owns its own internal state
  • has its own constructor and parameters
  • is explicitly constructed via Observable.from(object, param1, param2, …)
  • is not cached

From a typing perspective:

  • all trait methods exist
  • all trait properties are guaranteed to be present

This allows traits to carry state without polluting the original object.

Both modes exist because they solve different problems. A mode can be chosen for a specific trait using an option:

class MyTrait {
  static from = trait(MyTrait, { stateful: true });

  public myState?: number = 42;

  constructor(myParameter: number) {}
}

Trait Views as capability boundaries

So far, Trait Views look like a way to share behavior.
But they also reduce surface area.

class Disposable {
  static from = trait(Disposable)

  dispose(): void {
    console.log("default dispose")
  }
}
class Resource implements Disposable {
  secret = "do not touch"
  disposed = false

  dispose() {
    this.disposed = true
  }

  dangerousOperation() {
    console.log(this.secret)
  }
}
const resource = new Resource()
const disposable = Disposable.from(resource)

disposable.dispose()            // OK
disposable.dangerousOperation() // ❌ not accessible

The trait view exposes only one capability: disposal.

The original object may have many methods, many states, many invariants — but the view deliberately restricts what is visible. Instead of passing objects around, you pass what they are allowed to do.

Trait Views are not just about reuse. They are about encapsulation by projection.

How this works (conceptually)

At a high level, a trait view:

  • uses the trait prototype for method lookup
  • forwards calls to the underlying target object (stateless) or to its own internal state (stateful)
  • is created via the static from helper generated by trait(...)
  • may be cached per target when stateless, ensuring identity preservation

(Implementation details are omitted; the focus is on the observable API and type‑level guarantees.)

Default Behavior

  • Binds overridden methods to the original object
  • Optionally binds accessors
  • Caches stateless views (weakly, so they do not prevent garbage collection)
  • Freezes stateless views for stability

The original object is never mutated.
The trait view is a separate object with a clearly defined surface.

How This Compares to Rust Traits

Trait Views are inspired by Rust traits, but they are not the same thing.

Similarities

  • Behavior‑oriented abstraction
  • Default methods
  • Dynamic dispatch through a stable interface

Differences

  • Resolution is runtime, not compile‑time
  • There are no coherence or orphan rules
  • Overrides are name‑based
  • TypeScript cannot express all guarantees

This is not a flaw of the pattern. It is a consequence of JavaScript’s dynamic nature.

Trait Views aim for similar ergonomics, not identical semantics.

Why Not Just Bind Functions?

At first glance, a Trait View may look trivial. After all, one could create a new object and bind a few methods manually.

The difference is not in what happens at runtime — it is in what is being modeled.

Trait Views provide:

  • A consistent abstraction
  • A clear boundary between object state and trait behavior
  • A stable, typed surface
  • Optional caching and freezing guarantees
  • A shared mental model across a codebase

Manually binding functions solves a local problem.
Trait Views aim to solve a systemic one.

They are less about convenience and more about expressing intent.

Comparison with Existing Libraries

Several libraries already explore traits in JavaScript and TypeScript. Trait Views are not meant to replace them — they target a different focus.

@traits-ts/core

  • Provides a trait (or mixin) facility to extend classes with multiple base functionalities.
  • Leverages TypeScript’s type system and the regular class extends mechanism.
  • Combines trait behaviors into new class hierarchies with compile‑time type safety.

Use case: static composition where traits are known and applied at type definition time.

Trait Views focus on runtime adaptation and per‑instance views of existing objects, rather than static composition.

traits.js

  • Dedicated library for trait composition, building on classic reusable units of behavior.
  • Allows composing zero or more traits into a single composite trait, then constructing objects with the combined behavior.

Use case: creating new objects with combined behavior at construction time.

Trait Views take the opposite perspective: they derive objects from traits, creating views over existing objects without mutating them.

Trade‑offs and Limitations

  • Trait Views rely on runtime reflection.
  • They assume that getters are side‑effect free.
  • They cannot prevent all forms of monkey‑patching.
  • They require discipline in API design.

This pattern is not meant for everything. Trait Views are particularly well‑suited for:

  • Engines and simulations
  • ECS‑style architectures
  • Capability‑based APIs or systems where surface control matters

They are not ideal for simple CRUD applications or UI‑heavy codebases.

Conclusion — and an Open Question

Trait Views are not a new language feature. They are a pattern — a way to think differently about behavior in JavaScript.

They sit somewhere between:

  • Rust‑style traits
  • Capability‑based design
  • Runtime object views

At this stage, Trait Views are an experiment. There is no public library yet — only an idea, an implementation, and a set of trade‑offs.

If this resonates with you

  • As a user
  • As a library author
  • As someone who cares about language design

then feedback matters.

Maybe you’ve already solved similar problems in a different way and would like to share your approach.

  • Should this remain a pattern?
  • Should it become a small experimental library?
  • Or should it stay an internal tool for specialized systems?

I’m genuinely interested in hearing what the community thinks.

Back to Blog

Related posts

Read more »