Scala 2 had no good enum. scala.Enumeration gave you named integers with awkward types and no exhaustivity checking, so most teams wrote a sealed trait with case objects and hand-rolled the list of values. Scala 3 replaced both with one construct, enum, that covers three needs: a fixed set of names, a fixed set of names with data attached, and a full algebraic data type whose cases carry fields and type parameters.
This page covers enums themselves: what each form compiles to, the methods the compiler generates and when it generates them, the type inference rules that surprise people, Java interop, how to put enums on the wire without breaking clients, and the failure modes. The broader design question of when a sealed trait hierarchy beats an enum is covered in sealed traits and ADTs; here we assume you have chosen an enum and want to use it well.
Three forms of enum
The simplest form is an enumeration: cases with no parameters. Every case is a singleton value, and the compiler generates an ordinal on each value and three lookups on the companion.
enum Priority:
case Low, Medium, High
Priority.High.ordinal // 2: position in declaration order
Priority.values.toList // List(Low, Medium, High)
Priority.valueOf("Medium") // Medium
Priority.fromOrdinal(0) // Low
Priority.High.toString // "High"
Priority.valueOf("Urgent") // throws: no case with that nameThe second form attaches data through constructor parameters on the enum itself. Each case must then say how it calls that constructor with an extends clause. The enum body can hold methods shared by every case.
enum HttpStatus(val code: Int, val retryable: Boolean):
case Ok extends HttpStatus(200, false)
case NotFound extends HttpStatus(404, false)
case TooManyRequests extends HttpStatus(429, true)
case ServiceUnavailable extends HttpStatus(503, true)
def isError: Boolean = code >= 400
object HttpStatus:
private val byCode: Map[Int, HttpStatus] = values.map(s => s.code -> s).toMap
def fromCode(code: Int): Option[HttpStatus] = byCode.get(code)The user-written companion merges with the generated one, so values is in scope there. One restriction trips people up: an enum case's declaration may not refer directly to members of the companion object, even if imported. Put shared constants in a separate object, or inline them.
The third form is the algebraic data type: cases with their own parameters, optionally generic.
enum Result[+E, +A]:
case Ok(value: A)
case Err(error: E)
def map[B](f: A => B): Result[E, B] = this match
case Result.Ok(v) => Result.Ok(f(v))
case Result.Err(e) => Result.Err(e)
What the compiler generates
An enum is syntax for a sealed abstract class plus a companion object. Singleton cases become values in the companion, created by a shared private factory that records each case's ordinal and name. Cases with parameters become case classes extending the enum, so they get copy, equals, unapply and the rest. Because the parent is sealed, a match over an enum gets the same exhaustivity check as a sealed trait.
The detail that matters most in practice: values and valueOf cover singleton cases only. An enum mixing both kinds has a values array that silently omits every case with parameters, which is correct by definition (there is no single Paid value to return) but surprising if you used values to drive a UI or a test that "covers every case". ordinal exists on every case value, singleton or not, and fromOrdinal is a lookup you should use only on pure enumerations.
Equality follows from the encoding. Singleton cases are single objects, so comparing them is a reference check and they are safe as map keys or in sets. Class cases are case classes, so Paid("p-1") == Paid("p-1") is structural and true. Behaviour that differs per case belongs in a method on the enum body that matches on this, as Result.map does above; cases cannot carry their own method bodies, which keeps all the variation for one operation in one place and lets the exhaustivity check cover it. If you find yourself writing the same match in many methods, that is a sign the type wants to be a sealed trait instead, with members implemented per subclass.
Enums also provide a Mirror.SumOf, which is what derives clauses and libraries such as circe use for type class derivation. That is how enum Side derives CanEqual or a JSON codec derivation works without hand-written instances; see circe in depth for one library's rules.
Two type rules that surprise people
Two inference rules decide the types you see, and both differ from the sealed-trait encoding.
Constructors widen to the enum type. Result.Ok(1) has static type Result[Nothing, Int], not Result.Ok[...]. That is usually what you want: an Option-like type should infer as the parent, so if cond then Ok(1) else Err("x") has a useful type without annotations. When you need the precise case type, ask for it with new Result.Ok(1) or an expected type such as val ok: Result.Ok[Nothing, Int] = Result.Ok(1).
Singleton cases of generic enums get inferred parents by variance. In enum Opt[+A], case Empty with no extends becomes Opt[Nothing]: covariant parameters are minimised and contravariant ones maximised. An invariant parameter has no safe choice, so the compiler requires an explicit clause:
enum Opt[+A]:
case Some(value: A) // becomes Some[A] extends Opt[A]
case Empty // inferred: extends Opt[Nothing]
enum Cell[A]: // invariant
case Full(value: A)
case Blank extends Cell[Unit] // required: the compiler cannot pick a type argumentIf you see an error about a missing extends clause on a case, this is why. Either make the parameter covariant (when it is only produced, never consumed) or write the parent you mean.
Worked example: an order state machine
A realistic use is a state machine. An order moves through states, some carrying data, and the transition function must reject illegal moves. The enum keeps states and events closed, and the match is checked for completeness.
enum OrderEvent:
case Pay(paymentId: String)
case Ship(tracking: String)
case Cancel(reason: String)
enum OrderState derives CanEqual:
case Created
case Paid(paymentId: String)
case Shipped(paymentId: String, tracking: String)
case Cancelled(reason: String)
object Orders:
import OrderState.*, OrderEvent.*
def next(s: OrderState, e: OrderEvent): Either[String, OrderState] = (s, e) match
case (Created, Pay(id)) => Right(Paid(id))
case (Paid(pid), Ship(t)) => Right(Shipped(pid, t))
case (Created | Paid(_), Cancel(r)) => Right(Cancelled(r))
case (Shipped(_, _), _) => Left(s"order already shipped; cannot apply $e")
case (Cancelled(_), _) => Left(s"order cancelled; cannot apply $e")
case (Created, Ship(_)) => Left("cannot ship before payment")
case (Paid(_), Pay(_)) => Left("already paid")
// Orders.next(Created, Pay("p-1")) == Right(Paid("p-1"))Notice what the match does not have: a final case _. With every combination written out, adding a Refunded state next quarter produces a non-exhaustive match warning at this exact function, which is the whole point. A wildcard would compile silently and route the new state into whichever branch it happened to fall through to. Make the warning fatal with -Werror in scalacOptions, so it cannot be ignored.
The derives CanEqual clause matters if you enable strict equality (-language:strictEquality). With it, state == OrderEvent.Pay("x") is a compile error instead of an always-false comparison, which catches a real class of bugs in state-machine code where states and events have similar names.
Java interop
Scala enums are not Java enums by default. To make one visible to Java as a java.lang.Enum, extend it explicitly; the compiler supplies the name and ordinal constructor arguments.
enum Level extends java.lang.Enum[Level]:
case Debug, Info, Warn, ErrorJava code then sees an enum subclass with name() and ordinal(), and Java frameworks that accept enum types can work with it. Test the specific Java APIs you depend on (switch statements in Java callers, EnumSet, annotation processors) against your compiler version before you rely on them, and keep such enums to simple cases: the Java view has no place for cases with their own parameters.
Enums on the wire
Enums leak into JSON, database columns and message schemas, and the defaults are dangerous for long-lived data.
- Never persist the ordinal. Inserting a case in the middle renumbers everything after it, and old rows now decode to the wrong value without any error.
- Be careful persisting the case name.
toStringandvalueOfuse the Scala identifier, so a rename for code style breaks every stored value. - Give wire names their own field. A constructor parameter decouples the stored form from the identifier and makes unknown values an explicit error instead of an exception.
enum Currency(val wire: String):
case Usd extends Currency("USD")
case Eur extends Currency("EUR")
case Inr extends Currency("INR")
object Currency:
private val byWire: Map[String, Currency] = values.iterator.map(c => c.wire -> c).toMap
def parse(s: String): Either[String, Currency] = byWire.get(s).toRight(s"unknown currency: $s")To retire a case without breaking readers, keep it and mark it deprecated. Code outside the enum gets a warning when it uses the case, while code inside the enum (such as the lookup map) can still refer to it, so old data keeps decoding.
enum Plan(val wire: String):
case Free extends Plan("free")
case Team extends Plan("team")
@deprecated("use Team; kept so stored 'business' rows still decode")
case Business extends Plan("business")
Migrating from Scala 2
Moving from Scala 2 encodings is mechanical once the wire format is pinned down:
| Scala 2 | Scala 3 enum | Watch for |
|---|---|---|
object Color extends Enumeration { val Red, Green = Value } | enum Color: case Red, Green | Code that relied on the Value type or on Enumeration ordering methods |
Color.withName("Red") | Color.valueOf("Red") | Different failure behaviour; wrap it in a total parse function |
Color.Red.id | Color.Red.ordinal | Enumeration ids could be set explicitly; ordinals cannot. Move custom numbers into a parameter |
sealed trait S; case object A extends S | enum S: case A | Case objects had precise singleton types; enum constructors widen |
sealed trait S; final case class B(x: Int) extends S | enum S: case B(x: Int) | Cases cannot declare their own members; move behaviour to the enum body and dispatch with a match |
Before switching serialization, write a test that round-trips every stored value through the old and new decoders. If the old code used Enumeration ids or names on the wire, that test is the only thing that will catch a renumbering.
Failure modes
Failure modes seen in real code:
- Using
valuesas the list of all cases on an enum with parameterised cases. It contains only singletons. Write an explicit list, or test exhaustiveness through a match. - Calling
valueOforfromOrdinalon untrusted input. Both throw. Expose a parse function returningOptionorEitherand use it at every boundary. - Wildcards in matches over your own enums. They switch off the check that makes enums worth using.
- Exhaustivity warnings left as warnings. The build passes and the bug ships. Use
-Werror. - Surprise at widened types when overloading or when a method wants a specific case. Use
newor an expected type. - Growing an enum into a hierarchy. Once cases need their own members, extra parents or nested groupings, an enum fights you. Move to a sealed trait; case classes and opaque types cover the pieces you will reach for.
What to do next
- Replace each
Enumerationand each sealed trait of case objects with anenum, keeping a round-trip test for stored values. - Give every enum that crosses a process boundary an explicit wire field and a total
parsefunction; ban ordinals in storage. - Turn on
-Werrorand remove wildcard cases from matches over your own enums. - Add
derives CanEqualand try-language:strictEqualityin one module to see what it catches. - Audit uses of
valueson enums with parameterised cases. - Retire cases with
@deprecatedrather than deleting them while old data exists. - Read the Scala 3 overview for how enums fit with union types and opaque aliases.