Skip to content

Traits & Behaviours

JavaScript’s built-in operators answer surprisingly few questions about your data. === compares objects by reference, not by content. < only works for a handful of primitives. There is no standard way to ask “do these two values mean the same thing?” or “which of these comes first?” for your own types.

Effect closes that gap with a small set of traits — interfaces and modules that describe behaviours a value can have. The most important ones are:

  • Equal / Hash — value-based equality. Two values are equal when their contents match, not when they share a reference. Hash is the companion that lets hash-based collections (HashMap, HashSet) bucket values quickly.
  • Order — a total ordering. An Order<A> is a comparison function that powers sorting, min/max, clamping, and range checks.
  • Equivalence — a relaxed notion of equality. An Equivalence<A> decides when two values should be treated as equivalent for a particular purpose (case-insensitive strings, dates by timestamp, records by a single field).

These traits are the foundation a lot of the rest of Effect is built on. When you compare two Options, deduplicate an Array, or store domain objects in a HashSet, you are relying on Equal, Order, and Equivalence under the hood.

import { Equal, Order, Array } from "effect"
// Value equality: same contents, different references
console.log(Equal.equals({ id: 1 }, { id: 1 })) // true
console.log({ id: 1 } === { id: 1 }) // false (reference equality)
// A reusable comparator for objects, derived from a primitive Order
const byAge = Order.mapInput(
Order.Number,
(user: { name: string; age: number }) => user.age
)
const users = [
{ name: "Charlie", age: 30 },
{ name: "Bob", age: 25 }
]
console.log(Array.sort(users, byAge).map((u) => u.name))
// ["Bob", "Charlie"]

Equal.equals walks the structure of both values and compares them field by field. Order.mapInput adapts the built-in Order.Number so it can compare whole user objects by their age. Neither requires you to write any comparison logic by hand — you compose existing behaviours instead.

There are two ways a type participates in these behaviours:

  1. The type implements the interface. Classes can implement Equal and Hash directly so that Equal.equals and hash collections understand them. This is how Data classes get structural equality for free.
  2. You supply an instance separately. Order<A> and Equivalence<A> are plain functions you build and pass to APIs like Array.sort or Array.dedupeWith. The value itself does not need to know anything about them.

Equal and Equivalence look similar but serve different roles. Equal is the one canonical structural equality used throughout Effect; Equivalence lets you define many situational notions of “the same” for a single type.

If you come from a language with a real trait/typeclass system — Rust traits, Haskell typeclasses, Scala givens — Effect’s traits can feel magical, because there is no impl Equal for UserId block and no compiler resolving instances. There is no language-level machinery at all. Effect’s traits are a convention implemented with two plain JavaScript techniques, and which one a trait uses explains everything about how you work with it.

Style 1: a method on the value (Equal, Hash)

Section titled “Style 1: a method on the value (Equal, Hash)”

Equal and Hash are not separate instances. The value carries its own implementation as a method stored under a well-known property key. That key is just a namespaced string — Equal.symbol is literally "~effect/interfaces/Equal" and Hash.symbol is "~effect/interfaces/Hash":

import { Equal, Hash } from "effect"
class UserId implements Equal.Equal {
constructor(readonly id: string) {}
// [Equal.symbol] is the same as ["~effect/interfaces/Equal"]
[Equal.symbol](that: Equal.Equal): boolean {
return that instanceof UserId && this.id === that.id
}
[Hash.symbol](): number {
return Hash.string(this.id)
}
}

Dispatch is then just a runtime property lookup. Equal.equals asks “does this object have the equality method?” and, if both sides do, calls it:

// This is essentially what Equal.equals does (simplified):
const isEqual = (u: unknown) => hasProperty(u, Equal.symbol) // "does u have the method?"
function equals(self: unknown, that: unknown): boolean {
if (isEqual(self) && isEqual(that)) {
return self[Equal.symbol](that) // delegate to the value's own method
}
// ...otherwise fall back to built-in structural comparison
}

This is structural, runtime duck typing, not nominal compile-time resolution. Any object that happens to carry a [Equal.symbol] method participates — there is no registry, no coherence check, and no “orphan rule”.

You have already seen this exact pattern in plain JavaScript. for...of works on anything with a [Symbol.iterator] method; await works on any “thenable” with a .then method; String(x) consults [Symbol.toPrimitive]. Effect simply defines its own set of protocol keys (Equal, Hash, and many others across the library) and dispatches on them the same way. The only twist is that Effect uses namespaced strings rather than real Symbols, so the keys stay stable and inspectable across module instances and realms.

Style 2: a value you pass around (Order, Equivalence)

Section titled “Style 2: a value you pass around (Order, Equivalence)”

Order<A> and Equivalence<A> work the other way. They are not methods on your type at all — an Order<A> is just a comparison function, and an Equivalence<A> is just a (a, b) => boolean:

import { Order } from "effect"
// An Order<number> is literally this function — nothing more
const byNumber: Order<number> = (self, that) =>
self < that ? -1 : self > that ? 1 : 0

You build these instances and hand them to APIs like Array.sort(users, byAge). The value being compared knows nothing about them; the behaviour travels separately, as an argument.

This is the dictionary-passing model — the same thing Haskell does under the hood when it compiles a typeclass constraint, and what Scala’s cats/ZIO ecosystem (Effect’s lineage) expresses with implicit/given instances. Effect keeps the instances but drops the implicit resolution: instead of the compiler finding the right Ord for you, you pass it explicitly, or reach for a canonical one a module already exposes (Order.Number, Order.String, …) and compose it with combinators like Order.mapInput.

Rust traitEffect Equal/HashEffect Order/Equivalence
Where the impl livesimpl block, attached to the type at compile timea method on the value, keyed by a stringa standalone value you construct
Resolutioncompiler picks the impl statically (monomorphized)runtime property lookup (duck typed)you pass the instance by hand
Coherence / orphan ruleenforced — one impl per typenone — any object with the key qualifiesnone — you can build many
Multiple behaviours per typeno (one impl Ord)one canonical equalityyes — as many Orders as you like
Costzero-cost, no runtime dataa method call + a hasProperty checkan extra function argument

The trade Effect makes is Rust’s compile-time guarantees for JavaScript-native flexibility: traits are opt-in at runtime, any value can carry one, and the “instance” styles let a single type have many behaviours (sort users by age or by name) without the language needing to know about traits at all.

  • Equal & Hash — value equality with Equal, the Hash contract, and how to implement both on your own classes.
  • Order — building total orders, composing them for multi-field sorting, and turning them into predicates and range checks.
  • Equivalence — defining custom equality relations and combining them for structs, tuples, arrays, and records.
  • Data TypesData classes implement Equal/Hash automatically.
  • Schema — derive an Equivalence from a schema with Schema.toEquivalence.