DEV Community

Léo Sarrazin
Léo Sarrazin

Posted on

Trait Views: exposing behavior in JavaScript without inheritance

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

Let’s start with something deliberately simple.

class Observable {
  static from = trait(Observable)

  observe(): number {
    return 0
  }

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

This 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

And now:

const sensor = new Sensor()

Observable.from(sensor).observeTwice() // 42
Enter fullscreen mode Exit fullscreen mode

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

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, this means:

  • 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<Observable>

A stateful trait view:

  • owns its own internal state
  • have 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) {}
}
Enter fullscreen mode Exit fullscreen mode

Trait Views as capability boundaries

So far, Trait Views look like a way to share behavior.

But they do something else that is just as important: they reduce surface area.

class Disposable {
  static from = trait(Disposable)

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

  dispose() {
    this.disposed = true
  }

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

disposable.dispose()            // OK
disposable.dangerousOperation() // ❌ not accessible
Enter fullscreen mode Exit fullscreen mode

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 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 the idea of traits in JavaScript and TypeScript.
Trait Views are not meant to replace them — they are designed with a different focus.

@traits-ts/core

@traits-ts/core is a library for TypeScript that provides a trait (or mixin) facility to extend classes with multiple base functionalities, even though JavaScript does not natively allow multiple inheritance.

It leverages TypeScript’s type system and the regular class extends mechanism to combine trait behaviors into new class hierarchies with compile-time type safety. This makes it well-suited for static composition, where traits are known and applied as part of the type definition.

Trait Views explore a different space: they focus on runtime adaptation and per-instance views of existing objects, rather than static composition of classes.

traits.js

traits.js is a dedicated JavaScript library for trait composition, building on a classic definition of traits as reusable units of behavior.

It provides a way to compose zero or more traits into a single composite trait and then use that to construct objects with the combined behavior. This model emphasizes combining behavior into new objects as part of object construction.

Trait Views take a different perspective: instead of composing traits into objects, they derive objects from traits, creating views over existing objects without mutating them.

The difference is less about capability, and more about direction and use case.


Tradeoffs and limitations

Trait Views are not free.

They 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 tradeoffs.

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.

Top comments (1)

Collapse
 
lsarrazi profile image
Léo Sarrazin

I’m particularly interested in hearing from people who solved similar problems differently, or who ran into limitations with traits / mixins in JS.