On This Page
What the desk prints when you're done
This is the output of the program you're about to write. A library charges late fees, and the amount depends on two things: who borrowed the item (their membership tier) and what it is (a book or a DVD). Both of those are enums.
TIER ITEM DUE LATE FEE
BASIC BOOK 2026-03-16 $8.75
BASIC DVD 2026-03-16 $17.50
STANDARD BOOK 2026-03-23 $5.60
STANDARD DVD 2026-03-23 $11.20
PREMIUM BOOK 2026-04-13 $0.00
PREMIUM DVD 2026-04-13 $0.00
code 'S' -> STANDARD
code 'X' -> Optional.empty
ON_LOAN -> OVERDUE allowed? true
LOST -> AVAILABLE allowed? false
There's no if (tier == BASIC) chain anywhere in the code that produced it. Loan length, loan limits and fine rates live on the tier itself, and the fee formula lives on the item type. That's the point of a Java enum with fields and methods: each constant carries its own data and, when it needs to, its own behaviour. You'll build exactly that, with a lookup by short code and a small state machine, and then break it on purpose seven ways so you recognise the compiler messages when they show up in your own code.
You should already be comfortable with classes, static, and switch. You don't need to know anything about enums beyond "a fixed list of constants". If you want the bare-bones introduction first, there's an intro to Java enums on this site. This article starts where that one stops.
I'll use a library catalogue as the example domain. The build took me about an hour, and most of that went on the compile errors in the second-to-last section, because I wanted the exact messages rather than the ones I remembered.
Versions I used
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| Build tool | none; plain javac |
|
| IDE | IntelliJ IDEA or Eclipse (steps from the vendors' docs, not executed) | Help → About |
Enums have worked this way since Java 5. I also compiled everything with javac --release 17 and it went through, so Java 17 readers are fine. No Maven, no Gradle, no container: the whole project is five files.
Set up the library desk project
Make a folder with this layout. The package name is com.example.library; change it if you like, but keep the folder structure in step with it.
enums/
└── src/com/example/library/
├── MembershipTier.java
├── MediaType.java
├── LoanStatus.java
├── LoanDesk.java
├── Main.java
└── Checks.java
Compile and run from the enums folder (not from inside src; I did that wrong on my first attempt and got file not found):
mkdir -p out
javac -Xlint:all -d out src/com/example/library/*.java
java -cp out com.example.library.Main
On Windows PowerShell, replace the glob with (Get-ChildItem src -Recurse -Filter *.java).FullName.
In IntelliJ IDEA: File → New → Project, choose "New Project", language Java, build system IntelliJ, JDK 21. Create the package under src and paste the files in. Right-click Main and choose Run. In Eclipse: File → New → Java Project, set the execution environment to JavaSE-21, then create the package and the files. Run As → Java Application on Main. I didn't run either IDE for this article; the menu names come from the vendors' current documentation.
If you'd rather use Maven or Gradle, put the files under src/main/java/com/example/library/ and any standard Java 21 build file will do. Nothing here needs a dependency.
How to use Java enums with fields and methods, step by step
Step 1: Give each constant its own data
Goal: model the membership tiers with a loan length, a loan limit and a fine rate attached to each constant.
src/com/example/library/MembershipTier.java
package com.example.library;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public enum MembershipTier {
BASIC("B", 14, 3, 25),
STANDARD("S", 21, 8, 20),
PREMIUM("P", 42, 20, 0);
private static final Map<String, MembershipTier> BY_CODE = new HashMap<>();
static {
for (MembershipTier tier : values()) {
BY_CODE.put(tier.code, tier);
}
}
private final String code;
private final int loanDays;
private final int maxLoans;
private final int finePerDayCents;
MembershipTier(String code, int loanDays, int maxLoans, int finePerDayCents) {
this.code = code;
this.loanDays = loanDays;
this.maxLoans = maxLoans;
this.finePerDayCents = finePerDayCents;
}
public String code() { return code; }
public int loanDays() { return loanDays; }
public int maxLoans() { return maxLoans; }
public int finePerDayCents() { return finePerDayCents; }
public static Optional<MembershipTier> fromCode(String code) {
return Optional.ofNullable(BY_CODE.get(code));
}
}
Four things are worth looking at here.
The constants (BASIC("B", 14, 3, 25)) are calls to the constructor. The list of constants comes first and ends with a semicolon, and only then do the fields and methods start. Forget that semicolon and you get a confusing error pointing at the next line.
The constructor has no public or private in front of it. It's implicitly private, and you can't change that, which is how Java guarantees nobody creates a fourth tier at runtime.
Fine rates are int cents, not double dollars. Money in floating point goes wrong in ways that show up on customers' invoices; I cover that in the BigDecimal article. For a whole-cents value an int is enough.
The fields are final. An enum constant is shared by the whole JVM. If one field were mutable, changing it in one place would change it everywhere.
Checkpoint: compile what you have so far.
javac -d out src/com/example/library/MembershipTier.java
No output means it compiled.
Step 2: Look a constant up by its short code
Goal: turn the "S" stored in a database column or a form back into STANDARD, without a loop at every call site.
This is already in the file above, in the BY_CODE map and the static block that fills it. Look at the order of the declarations: the map is declared before the static block that uses it. That ordering isn't style. Move the field below the block and javac refuses:
MembershipTier.java:14: error: illegal forward reference
BY_CODE.put(tier.code, tier);
^
Also note why I fill the map in a static block and not in the constructor. The constructor runs once per constant, while the class is still being initialised, and Java doesn't allow it to touch static fields. You'll see that error in the troubleshooting section.
fromCode returns an Optional, so a caller who passes "X" can't forget to handle the miss. The built-in valueOf(String) is different: it takes the constant's name ("STANDARD"), is case-sensitive, and throws IllegalArgumentException when nothing matches. I'd keep valueOf for names you control and use your own lookup for anything coming from outside. If you want the Optional-chaining style used here, see how to use Optional.
Step 3: Give each constant its own behaviour
Goal: let each media type calculate its own late fee, so the fee logic can't drift away from the type.
src/com/example/library/MediaType.java
package com.example.library;
public enum MediaType {
BOOK("BK", true) {
@Override
public int lateFeeCents(int daysLate, MembershipTier tier) {
return Math.min(daysLate * tier.finePerDayCents(), 2_000);
}
},
DVD("DV", true) {
@Override
public int lateFeeCents(int daysLate, MembershipTier tier) {
return Math.min(daysLate * tier.finePerDayCents() * 2, 3_000);
}
},
REFERENCE("RF", false) {
@Override
public int lateFeeCents(int daysLate, MembershipTier tier) {
throw new IllegalStateException("Reference items never leave the building");
}
};
private final String prefix;
private final boolean loanable;
MediaType(String prefix, boolean loanable) {
this.prefix = prefix;
this.loanable = loanable;
}
public String prefix() { return prefix; }
public boolean loanable() { return loanable; }
public abstract int lateFeeCents(int daysLate, MembershipTier tier);
}
lateFeeCents is declared abstract on the enum, and every constant supplies its body inside braces. That's called a constant-specific class body. Behind the scenes each of those constants becomes its own anonymous subclass of MediaType. The compiler enforces this: delete the REFERENCE body and it tells you the enum is abstract and can't be instantiated.
A DVD costs double per day and has a higher cap; a book is capped at $20.00. REFERENCE shows the other option for a constant that has no sensible answer: throw. I like this better than returning 0, because a silent zero would hide a bug in the caller.
The loanable field is separate on purpose. A flag on the constant is cheaper than overriding a method when the answer is plain data.
Checkpoint:
javac -d out src/com/example/library/MembershipTier.java src/com/example/library/MediaType.java
Step 4: Model allowed state changes with EnumMap and EnumSet
Goal: record which loan status can follow which, so LOST can never become AVAILABLE.
src/com/example/library/LoanStatus.java
package com.example.library;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
public enum LoanStatus {
AVAILABLE, ON_LOAN, OVERDUE, LOST;
private static final Map<LoanStatus, Set<LoanStatus>> NEXT = new EnumMap<>(LoanStatus.class);
static {
NEXT.put(AVAILABLE, EnumSet.of(ON_LOAN, LOST));
NEXT.put(ON_LOAN, EnumSet.of(AVAILABLE, OVERDUE, LOST));
NEXT.put(OVERDUE, EnumSet.of(AVAILABLE, LOST));
NEXT.put(LOST, EnumSet.noneOf(LoanStatus.class));
}
public boolean canMoveTo(LoanStatus target) {
return NEXT.get(this).contains(target);
}
}
Here the constants have no fields at all. The transitions live in a map, and the map lives outside the constants for a reason: you can't pass ON_LOAN to AVAILABLE's constructor, because it hasn't been created yet. Passing later constants into an earlier one's constructor (AVAILABLE(ON_LOAN, LOST)) fails with illegal forward reference, which I reproduced in the errors section. Building the table in a static block after every constant exists avoids the problem.
EnumMap and EnumSet are the collections made for enum keys. Internally they're an array indexed by ordinal and a bit set, so they're compact and iterate in declaration order. They're worth reaching for any time the key is an enum.
Step 5: Use the enums from ordinary code
Goal: a small class that does the date and fee arithmetic, plus a main that prints the table at the top of the article.
src/com/example/library/LoanDesk.java
package com.example.library;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public final class LoanDesk {
private LoanDesk() { }
public static LocalDate dueDate(LocalDate borrowed, MembershipTier tier) {
return borrowed.plusDays(tier.loanDays());
}
public static int lateFeeCents(MediaType type, MembershipTier tier,
LocalDate due, LocalDate returned) {
long late = ChronoUnit.DAYS.between(due, returned);
if (late <= 0) {
return 0;
}
return type.lateFeeCents((int) late, tier);
}
public static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
}
src/com/example/library/Main.java
package com.example.library;
import java.time.LocalDate;
public class Main {
public static void main(String[] args) {
LocalDate borrowed = LocalDate.of(2026, 3, 2);
LocalDate returned = LocalDate.of(2026, 4, 20);
System.out.printf("%-10s %-10s %-12s %-8s%n", "TIER", "ITEM", "DUE", "LATE FEE");
for (MembershipTier tier : MembershipTier.values()) {
LocalDate due = LoanDesk.dueDate(borrowed, tier);
for (MediaType type : MediaType.values()) {
if (!type.loanable()) {
continue;
}
int fee = LoanDesk.lateFeeCents(type, tier, due, returned);
System.out.printf("%-10s %-10s %-12s %-8s%n",
tier, type, due, LoanDesk.money(fee));
}
}
System.out.println();
System.out.println("code 'S' -> " + MembershipTier.fromCode("S").orElseThrow());
System.out.println("code 'X' -> " + MembershipTier.fromCode("X"));
System.out.println("ON_LOAN -> OVERDUE allowed? " + LoanStatus.ON_LOAN.canMoveTo(LoanStatus.OVERDUE));
System.out.println("LOST -> AVAILABLE allowed? " + LoanStatus.LOST.canMoveTo(LoanStatus.AVAILABLE));
}
}
LoanDesk never asks "which tier?" or "which type?". It asks the enum. If someone adds a STUDENT tier next month, LoanDesk doesn't change. For the date arithmetic see LocalDate and ZonedDateTime.
The loop uses values(), which returns every constant in declaration order. Each call hands back a fresh copy of the internal array, so don't call it inside a hot loop; keep a reference or use EnumSet.allOf.
Run it and read the table
javac -Xlint:all -d out src/com/example/library/*.java
java -cp out com.example.library.Main
You should see the table from the top of the article and nothing from -Xlint. Check one row by hand. A BASIC member borrowed on 2 March gets 14 days, so the due date is 16 March. They return on 20 April, which is 35 days late. At 25 cents a day that's $8.75 for a book, under the $20.00 cap. A DVD doubles the daily rate for $17.50. The STANDARD book is due 23 March, so 28 days late at 20 cents: $5.60. PREMIUM has a rate of 0, so everything is $0.00.
If those numbers match, the hard part's done.
Pin it down with 36 checks
I didn't have JUnit available offline when I built this, so Checks is a plain Java class that prints PASS or FAIL. It's a quick way to see what is guaranteed; for a real project, convert each check(...) into an assertThat(...) in a JUnit 5 test (there's a JUnit 5 tutorial planned for this site: how to write unit tests with JUnit 5).
src/com/example/library/Checks.java
package com.example.library;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
public class Checks {
static int passed, failed;
static void check(String name, boolean ok) {
if (ok) { passed++; System.out.println("PASS " + name); }
else { failed++; System.out.println("FAIL " + name); }
}
static boolean throwsType(Class<? extends Throwable> t, Runnable r) {
try { r.run(); return false; } catch (Throwable e) { return t.isInstance(e); }
}
public static void main(String[] args) {
// constants and fields
check("three tiers", MembershipTier.values().length == 3);
check("BASIC loan days", MembershipTier.BASIC.loanDays() == 14);
check("PREMIUM fine is zero", MembershipTier.PREMIUM.finePerDayCents() == 0);
check("ordinal follows declaration order", MembershipTier.STANDARD.ordinal() == 1);
check("name() matches identifier", MembershipTier.PREMIUM.name().equals("PREMIUM"));
// lookup
check("fromCode S", MembershipTier.fromCode("S").orElseThrow() == MembershipTier.STANDARD);
check("fromCode unknown is empty", MembershipTier.fromCode("X").isEmpty());
check("fromCode null is empty", MembershipTier.fromCode(null).isEmpty());
check("valueOf unknown throws IAE",
throwsType(IllegalArgumentException.class, () -> MembershipTier.valueOf("GOLD")));
check("valueOf is case sensitive",
throwsType(IllegalArgumentException.class, () -> MembershipTier.valueOf("basic")));
check("valueOf null throws NPE",
throwsType(NullPointerException.class, () -> MembershipTier.valueOf(null)));
check("every tier code round-trips",
Arrays.stream(MembershipTier.values())
.allMatch(t -> MembershipTier.fromCode(t.code()).orElseThrow() == t));
// values() is a fresh copy
check("values() returns a new array each call",
MembershipTier.values() != MembershipTier.values());
MembershipTier[] copy = MembershipTier.values();
copy[0] = null;
check("mutating the copy leaves the enum alone", MembershipTier.values()[0] == MembershipTier.BASIC);
// abstract behaviour
MembershipTier basic = MembershipTier.BASIC;
check("BOOK 3 days late, BASIC = 75c", MediaType.BOOK.lateFeeCents(3, basic) == 75);
check("DVD 3 days late, BASIC = 150c", MediaType.DVD.lateFeeCents(3, basic) == 150);
check("BOOK fee capped at 2000", MediaType.BOOK.lateFeeCents(500, basic) == 2_000);
check("DVD fee capped at 3000", MediaType.DVD.lateFeeCents(500, basic) == 3_000);
check("PREMIUM pays nothing", MediaType.BOOK.lateFeeCents(30, MembershipTier.PREMIUM) == 0);
check("REFERENCE refuses fees",
throwsType(IllegalStateException.class,
() -> MediaType.REFERENCE.lateFeeCents(1, basic)));
check("constant bodies are subclasses",
MediaType.BOOK.getClass() != MediaType.class);
check("getDeclaringClass is the enum",
MediaType.BOOK.getDeclaringClass() == MediaType.class);
check("REFERENCE not loanable", !MediaType.REFERENCE.loanable());
// desk
LocalDate borrowed = LocalDate.of(2026, 3, 2);
LocalDate due = LoanDesk.dueDate(borrowed, basic);
check("due date BASIC", due.equals(LocalDate.of(2026, 3, 16)));
check("returned on due date = no fee",
LoanDesk.lateFeeCents(MediaType.BOOK, basic, due, due) == 0);
check("returned early = no fee",
LoanDesk.lateFeeCents(MediaType.BOOK, basic, due, due.minusDays(5)) == 0);
check("returned 2 days late",
LoanDesk.lateFeeCents(MediaType.BOOK, basic, due, due.plusDays(2)) == 50);
check("money format", LoanDesk.money(2005).equals("$20.05"));
check("money format cents pad", LoanDesk.money(5).equals("$0.05"));
// state machine
check("AVAILABLE -> ON_LOAN", LoanStatus.AVAILABLE.canMoveTo(LoanStatus.ON_LOAN));
check("AVAILABLE -> OVERDUE not allowed", !LoanStatus.AVAILABLE.canMoveTo(LoanStatus.OVERDUE));
check("LOST is terminal", Arrays.stream(LoanStatus.values())
.noneMatch(LoanStatus.LOST::canMoveTo));
// EnumMap / EnumSet / compareTo / ==
Map<MembershipTier, Integer> members = new EnumMap<>(MembershipTier.class);
members.put(MembershipTier.PREMIUM, 2);
members.put(MembershipTier.BASIC, 9);
check("EnumMap iterates in declaration order",
members.keySet().iterator().next() == MembershipTier.BASIC);
check("EnumSet.range", EnumSet.range(MembershipTier.BASIC, MembershipTier.STANDARD).size() == 2);
check("compareTo uses ordinal", MembershipTier.BASIC.compareTo(MembershipTier.PREMIUM) < 0);
check("== and equals agree", MembershipTier.valueOf("BASIC") == MembershipTier.BASIC);
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
java -cp out com.example.library.Checks
PASS three tiers
PASS BASIC loan days
[... lines cut]
PASS compareTo uses ordinal
PASS == and equals agree
36 passed, 0 failed
Two of these checks are lessons in disguise. values() returns a new array each call proves the defensive copy, and mutating the copy leaves the enum alone shows you can't corrupt the constants by writing to that array. And constant bodies are subclasses is where this prints:
MediaType.BOOK.getClass() = class com.example.library.MediaType$1
MediaType.BOOK.getDeclaringClass() = class com.example.library.MediaType
If you ever write if (x.getClass() == MediaType.class) it will be false for BOOK. Use getDeclaringClass(), or better, compare constants with ==.
What javac builds from an enum
An enum is a class. javac turns MembershipTier into a final class that extends java.lang.Enum<MembershipTier>, with one public static final field per constant, and adds two methods for you: values() and valueOf(String). The private constructor is called once per constant, in declaration order, when the class is first loaded. That's why constant identity is safe: there's exactly one STANDARD in the JVM, so == works and is the right comparison. It's null-safe too, where .equals isn't.
Constants with a body (MediaType.BOOK) become anonymous subclasses, and the enum itself becomes abstract as soon as it has an abstract method. Those are the $1, $2 class files you'll see in out/.
Initialisation order is the source of every odd enum error. The constants are built first, then static fields and static blocks run in textual order. That is why a constructor can't read a static field (the field isn't ready), and why the lookup map has to be filled from a static block placed after it is declared.
Every enum also gets ordinal(), the position in the declaration. I don't use it for anything that is stored. Here's why:
I saved STANDARD.ordinal() (1), then recompiled the enum with a new STUDENT constant inserted before it, and looked the saved number up again:
saved ordinal = 1 -> STANDARD
saved ordinal 1 -> STUDENT
The stored 1 meant STANDARD yesterday and means STUDENT after a perfectly reasonable edit. Store the code field (or the name) and look it up with fromCode. Same thing for compareTo: it compares ordinals, so it follows declaration order, which is fine for display and wrong as a business rule.
Errors I forced, and what they mean
Each of these comes from my own javac 21.0.12.1 run, with the smallest change that triggers it.
You touch a static field from the constructor.
MembershipTier.java:30: error: illegal reference to static field from initializer
BY_CODE.put(code, this);
^
Constructors run while the class is still initialising. Fill static maps from a static block, as in Step 1.
You call new on an enum.
Bad2.java:2: error: enum classes may not be instantiated
By design. The constants you declared are the only instances.
You extend an enum.
Bad3.java:2: error: cannot inherit from final MembershipTier
Bad3.java:2: error: enum classes are not extensible
If you need extra variants, add constants, or have the enum implement an interface and write a second implementation of that interface.
You leave out an abstract method body.
MediaType.java:16: error: MediaType is abstract; cannot be instantiated
REFERENCE("RF", false);
^
The message is misleading until you realise the enum became abstract when you declared the method. Add the body to the constant.
You reference a later constant in a constructor argument.
Fwd.java:2: error: illegal forward reference
enum Fwd { A(B), B(A); ... }
^
Build such relationships after all constants exist, as in LoanStatus.
A switch expression doesn't cover every constant.
Sw.java:2: error: the switch expression does not cover all possible input values
This is a gift. When you later add a tier, every enum switch without a default stops compiling until it handles the new one. I wouldn't add default to those switches. The switch-expressions tutorial shows the pattern.
You write public on a constructor.
Pub.java:2: error: modifier public not allowed here
One runtime failure to know too:
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant com.example.library.MembershipTier.GOLD
at java.base/java.lang.Enum.valueOf(Enum.java:293)
That's valueOf("GOLD") on a name that doesn't exist. If you're parsing user input, go through fromCode.
Rules I'd put in a code review
Compare with ==. Keep fields final. Persist the code, never the ordinal. Don't add a default to a switch over your own enum. Keep values() out of tight loops, and cache an EnumSet or a List if you need the same set often.
If an enum grows past a few hundred lines or starts reading from a database, it has stopped being an enum; the data belongs in a table and a class. Enums are for sets that change when the code changes, not when the business does. Tier rates that Finance edits every quarter shouldn't be compiled into your JAR.
One thing I'd do next in a real system is put lateFeeCents behind an interface, so each library branch can plug in its own rule. That's the strategy pattern, and enums are a fine place to keep the default strategies.
Checklist
- Constants listed first, ending in a semicolon; fields
final; constructor implicit private. - Lookup by code through a static map filled in a static block placed after the field.
- Abstract method on the enum when behaviour differs per constant; a plain field when only data does.
EnumMapandEnumSetfor enum-keyed collections.- Stored value is the code, not the ordinal.
- Exhaustive switches without
default.
Enum questions people search for
Can a Java enum have fields and methods? Yes. Declare the fields, a private constructor, and pass values in each constant's parentheses (BASIC("B", 14, 3, 25)). Methods work like on any class, including static ones.
Can an enum have a constructor? Yes, but it's always private. You can't write public, and you can't call new on an enum.
How do I get an enum constant from a string or a code? valueOf("STANDARD") matches the exact name and throws if there is no match. For anything else, build a static map as in Step 2 and return an Optional.
Can an enum implement an interface or extend a class? It can implement interfaces. It can't extend a class, because it already extends java.lang.Enum.
Should I use ordinal()? Not for anything that is stored or sent over the wire. See the section on what javac builds.