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.Hashis the companion that lets hash-based collections (HashMap,HashSet) bucket values quickly.Order— a total ordering. AnOrder<A>is a comparison function that powers sorting,min/max, clamping, and range checks.Equivalence— a relaxed notion of equality. AnEquivalence<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.
A first taste
Section titled “A first taste”import { Equal, Order, Array } from "effect"
// Value equality: same contents, different referencesconsole.log(Equal.equals({ id: 1 }, { id: 1 })) // trueconsole.log({ id: 1 } === { id: 1 }) // false (reference equality)
// A reusable comparator for objects, derived from a primitive Orderconst 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.
How to think about traits
Section titled “How to think about traits”There are two ways a type participates in these behaviours:
- The type implements the interface. Classes can implement
EqualandHashdirectly so thatEqual.equalsand hash collections understand them. This is howDataclasses get structural equality for free. - You supply an instance separately.
Order<A>andEquivalence<A>are plain functions you build and pass to APIs likeArray.sortorArray.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.
How traits actually work
Section titled “How traits actually work”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 moreconst byNumber: Order<number> = (self, that) => self < that ? -1 : self > that ? 1 : 0You 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.
How this compares to Rust
Section titled “How this compares to Rust”| Rust trait | Effect Equal/Hash | Effect Order/Equivalence | |
|---|---|---|---|
| Where the impl lives | impl block, attached to the type at compile time | a method on the value, keyed by a string | a standalone value you construct |
| Resolution | compiler picks the impl statically (monomorphized) | runtime property lookup (duck typed) | you pass the instance by hand |
| Coherence / orphan rule | enforced — one impl per type | none — any object with the key qualifies | none — you can build many |
| Multiple behaviours per type | no (one impl Ord) | one canonical equality | yes — as many Orders as you like |
| Cost | zero-cost, no runtime data | a method call + a hasProperty check | an 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.
In this section
Section titled “In this section”- Equal & Hash — value equality with
Equal, theHashcontract, 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.
See also
Section titled “See also”- Data Types —
Dataclasses implementEqual/Hashautomatically. - Schema — derive an
Equivalencefrom a schema withSchema.toEquivalence.