Guide contents

Lesson 10 of 71

Enums and Annotations

An enum is a class with a fixed set of named instances. An annotation is metadata attached to a declaration that tools, the compiler, or the program itself can read. After this lesson the reader can write enums with behavior, choose EnumSet and EnumMap over general collections, and declare and read custom annotations.

Topic API or keyword
Enum basics enum, values, valueOf, ordinal, name
Enum behavior fields, constructors, constant-specific bodies
Enum collections EnumSet (bit vector), EnumMap (array)
Annotations @interface, @Retention, @Target, @Inherited, @Repeatable
Reading annotations isAnnotationPresent, getAnnotation, getAnnotationsByType

Example 1: the smallest enum and its generated methods

Each constant of an enum is a public static final field holding the single instance for that name. The compiler adds a static values() and valueOf(String) to every enum.

Java
public class EnumBasics {
    enum Side { BUY, SELL }

    public static void main(String[] args) {
        for (Side s : Side.values()) {
            System.out.println(s.name() + " ordinal=" + s.ordinal());
        }
        Side parsed = Side.valueOf("SELL");
        System.out.println(parsed == Side.SELL);
        System.out.println(Side.BUY.compareTo(Side.SELL) < 0);
        try {
            Side.valueOf("sell");
        } catch (IllegalArgumentException e) {
            System.out.println("IllegalArgumentException: " + e.getMessage());
        }
    }
}
Output
BUY ordinal=0
SELL ordinal=1
true
true
IllegalArgumentException: No enum constant EnumBasics.Side.sell

Facts shown here:

  • ordinal() is the declaration position, starting at 0. Reordering constants changes it, so storing ordinals in a database or a file breaks when someone inserts a constant. Store name() or an explicit code field instead.
  • Each constant exists once per class loader, so == is a correct comparison and never throws on null.
  • valueOf is case-sensitive and throws IllegalArgumentException for an unknown name.
  • Enums implement Comparable in ordinal order and every enum extends java.lang.Enum, so an enum cannot extend another class.

values() returns a new array on every call, because arrays are mutable. Hot code caches it in a private static final field.

Example 2: fields, constructors, and methods

An enum constant can pass arguments to a constructor. The constructor is implicitly private; code outside the enum cannot create new instances.

Java
import java.util.HashMap;
import java.util.Map;

public class EnumFields {
    enum Currency {
        USD("840", 2), JPY("392", 0), BHD("048", 3);

        private static final Map<String, Currency> BY_CODE = new HashMap<>();
        static {
            for (Currency c : values()) BY_CODE.put(c.isoCode, c);
        }

        private final String isoCode;
        private final int minorUnits;

        Currency(String isoCode, int minorUnits) {
            this.isoCode = isoCode;
            this.minorUnits = minorUnits;
        }

        long toMinor(long major) {
            long factor = 1;
            for (int i = 0; i < minorUnits; i++) factor *= 10;
            return major * factor;
        }

        static Currency fromCode(String code) {
            Currency c = BY_CODE.get(code);
            if (c == null) throw new IllegalArgumentException("unknown code " + code);
            return c;
        }
    }

    public static void main(String[] args) {
        System.out.println(Currency.fromCode("392"));
        for (Currency c : Currency.values()) {
            System.out.println(c + " 5 -> " + c.toMinor(5));
        }
    }
}
Output
JPY
USD 5 -> 500
JPY 5 -> 5
BHD 5 -> 5000

The lookup map is filled in a static block. An enum constructor cannot read a non-constant static field of its own enum, because constants are created first, before the other static fields are initialized; the compiler rejects such a read. The static block runs after all constants exist, so it can call values().

Example 3: constant-specific bodies and abstract methods

A constant can have its own class body that overrides methods. When the enum declares an abstract method, every constant must implement it, and the compiler reports a missing one.

Java
public class ConstantBodies {
    enum Op {
        ADD("+") { long apply(long a, long b) { return Math.addExact(a, b); } },
        SUB("-") { long apply(long a, long b) { return Math.subtractExact(a, b); } },
        MUL("*") { long apply(long a, long b) { return Math.multiplyExact(a, b); } };

        final String symbol;
        Op(String symbol) { this.symbol = symbol; }

        abstract long apply(long a, long b);
    }

    public static void main(String[] args) {
        for (Op op : Op.values()) {
            System.out.println("7 " + op.symbol + " 3 = " + op.apply(7, 3));
        }
        System.out.println(Op.ADD.getClass() == Op.class);
        System.out.println(Op.ADD.getDeclaringClass() == Op.class);
    }
}
Output
7 + 3 = 10
7 - 3 = 4
7 * 3 = 21
false
true

A constant with a body is an instance of an anonymous subclass, so getClass() returns that subclass. getDeclaringClass() always returns the enum type, which makes it the method to use when grouping constants by type.

Example 4: switch over enums

A switch expression over an enum is exhaustive when it lists every constant, so it needs no default. Adding a constant then breaks compilation at every switch that misses it, the same check that sealed types give.

Java
public class EnumSwitch {
    enum OrderType { MARKET, LIMIT, STOP }

    static String describe(OrderType t) {
        return switch (t) {
            case MARKET -> "fill at best price";
            case LIMIT -> "fill at limit or better";
            case STOP -> "becomes market at trigger";
        };
    }

    static boolean needsPrice(OrderType t) {
        return switch (t) {
            case LIMIT, STOP -> true;
            case MARKET -> false;
        };
    }

    public static void main(String[] args) {
        for (OrderType t : OrderType.values()) {
            System.out.println(t + ": " + describe(t) + ", price=" + needsPrice(t));
        }
        try {
            describe(null);
        } catch (NullPointerException e) {
            System.out.println("null -> NullPointerException");
        }
    }
}
Output
MARKET: fill at best price, price=false
LIMIT: fill at limit or better, price=true
STOP: becomes market at trigger, price=true
null -> NullPointerException

Case labels use the bare constant name (LIMIT, not OrderType.LIMIT; the qualified form is also allowed since Java 21). A switch without case null throws NullPointerException on null, as pattern matching describes.

Example 5: EnumSet and EnumMap

EnumSet stores membership as bits: EnumSet.noneOf returns a RegularEnumSet backed by one long when the enum has at most 64 constants, and a JumboEnumSet backed by a long[] otherwise (read from EnumSet.java in JDK 25). EnumMap stores values in an array indexed by ordinal(). Both iterate in declaration order, and both avoid hashing.

Java
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;

public class EnumCollections {
    enum Permission { READ, WRITE, DELETE, ADMIN }
    enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }

    public static void main(String[] args) {
        Set<Permission> user = EnumSet.of(Permission.READ, Permission.WRITE);
        Set<Permission> all = EnumSet.allOf(Permission.class);
        Set<Permission> missing = EnumSet.complementOf(EnumSet.copyOf(user));
        System.out.println(user + " " + missing);
        System.out.println(all.containsAll(user));
        System.out.println(user.getClass().getSimpleName());

        Set<Day> weekdays = EnumSet.range(Day.MON, Day.FRI);
        System.out.println(weekdays);

        Map<Day, Integer> trades = new EnumMap<>(Day.class);
        trades.put(Day.WED, 12);
        trades.put(Day.MON, 7);
        trades.put(Day.MON, trades.get(Day.MON) + 3);
        System.out.println(trades);
    }
}
Output
[READ, WRITE] [DELETE, ADMIN]
true
RegularEnumSet
[MON, TUE, WED, THU, FRI]
{MON=10, WED=12}

EnumMap prints in declaration order even though WED was inserted first. The table compares the choices.

Need General collection Enum collection Difference
Set of flags HashSet<Permission> EnumSet<Permission> one long vs a hash table of nodes; containsAll is a bitwise check
Map keyed by enum HashMap<Day, V> EnumMap<Day, V> array slot per constant, no hashing, sorted iteration
Bit flags as int int with masks EnumSet same memory cost class, but type-safe and printable

Example 6: enums as singletons and state machines

A one-constant enum is a singleton: the JVM creates the instance once during class initialization, serialization returns the same instance, and reflection cannot call an enum constructor. An enum also models a state machine, a fixed set of states with allowed transitions between them.

Java
import java.util.EnumSet;
import java.util.Set;

public class EnumStateMachine {
    enum IdGenerator {
        INSTANCE;
        private long next = 1;
        long nextId() { return next++; }
    }

    enum OrderState {
        NEW, PARTIALLY_FILLED, FILLED, CANCELLED;

        Set<OrderState> next() {
            return switch (this) {
                case NEW -> EnumSet.of(PARTIALLY_FILLED, FILLED, CANCELLED);
                case PARTIALLY_FILLED -> EnumSet.of(PARTIALLY_FILLED, FILLED, CANCELLED);
                case FILLED, CANCELLED -> EnumSet.noneOf(OrderState.class);
            };
        }

        OrderState moveTo(OrderState target) {
            if (!next().contains(target)) {
                throw new IllegalStateException(this + " -> " + target);
            }
            return target;
        }
    }

    public static void main(String[] args) {
        System.out.println("id " + IdGenerator.INSTANCE.nextId() + ", " + IdGenerator.INSTANCE.nextId());
        OrderState s = OrderState.NEW;
        s = s.moveTo(OrderState.PARTIALLY_FILLED);
        s = s.moveTo(OrderState.FILLED);
        System.out.println("state " + s);
        try {
            s.moveTo(OrderState.CANCELLED);
        } catch (IllegalStateException e) {
            System.out.println("rejected " + e.getMessage());
        }
    }
}
Output
id 1, 2
state FILLED
rejected FILLED -> CANCELLED

The singleton's next field is mutable and not thread-safe; an enum guarantees one instance, not safe access to its state. The transition table lives in one method, so adding a state forces an update to the exhaustive switch.

Example 7: declaring and reading annotations

An annotation type is declared with @interface. Its elements look like methods without parameters and may have defaults. Two meta-annotations (annotations on annotation types) control its use:

Meta-annotation Effect
@Retention(SOURCE) discarded by the compiler (@Override)
@Retention(CLASS) kept in the class file, not visible to reflection (the default)
@Retention(RUNTIME) visible to reflection
@Target({...}) where it may appear: TYPE, METHOD, FIELD, PARAMETER, RECORD_COMPONENT, and more
@Inherited a class annotation is also reported on subclasses (classes only, not interfaces or methods)
@Repeatable(Container.class) the annotation may appear more than once; the compiler wraps the copies in the container

Reflection is reading a program's classes, methods, and fields at run time through Class objects; packages, modules, and reflection covers it in full. This example uses only the annotation methods.

Java
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

public class AnnotationBasics {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Inherited
    @interface Component { String value() default "unnamed"; }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Repeatable(Tags.class)
    @interface Tag { String value(); }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface Tags { Tag[] value(); }

    @Retention(RetentionPolicy.CLASS)
    @Target(ElementType.TYPE)
    @interface BuildOnly {}

    @Component("pricer") @Tag("fx") @Tag("rates") @BuildOnly
    static class Pricer {}

    static class FastPricer extends Pricer {}

    public static void main(String[] args) {
        Component c = Pricer.class.getAnnotation(Component.class);
        System.out.println("component " + c.value());
        System.out.println("inherited " + FastPricer.class.isAnnotationPresent(Component.class));
        for (Tag t : Pricer.class.getAnnotationsByType(Tag.class)) {
            System.out.println("tag " + t.value());
        }
        System.out.println("direct Tag " + Pricer.class.getAnnotation(Tag.class));
        System.out.println("Tags container " + Pricer.class.isAnnotationPresent(Tags.class));
        System.out.println("BuildOnly visible " + Pricer.class.isAnnotationPresent(BuildOnly.class));
        System.out.println("FastPricer tags " + FastPricer.class.getAnnotationsByType(Tag.class).length);
    }
}
Output
component pricer
inherited true
tag fx
tag rates
direct Tag null
Tags container true
BuildOnly visible false
FastPricer tags 0

getAnnotation(Tag.class) returns null because two @Tag annotations are stored as one @Tags container; getAnnotationsByType looks through the container. @BuildOnly has CLASS retention, so reflection does not see it. Tag is not @Inherited, so the subclass reports no tags.

Example 8: a validator driven by annotations

Frameworks such as validation and serialization libraries read field annotations at run time. This example builds a small version: @NotBlank and @Range on record components, checked by a generic validate method. Annotations with target FIELD placed on a record component are propagated to the generated private field.

Java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

public class AnnotationValidator {
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
    @interface NotBlank {}

    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
    @interface Range { long min(); long max(); }

    record NewOrder(@NotBlank String symbol, @Range(min = 1, max = 1_000_000) long quantity,
                    @Range(min = 1, max = 100_000) long priceCents) {}

    static List<String> validate(Object target) throws IllegalAccessException {
        List<String> errors = new ArrayList<>();
        for (Field f : target.getClass().getDeclaredFields()) {
            f.setAccessible(true);
            Object value = f.get(target);
            if (f.isAnnotationPresent(NotBlank.class)
                    && (value == null || value.toString().isBlank())) {
                errors.add(f.getName() + " must not be blank");
            }
            Range r = f.getAnnotation(Range.class);
            if (r != null) {
                long v = (Long) value;
                if (v < r.min() || v > r.max()) {
                    errors.add(f.getName() + " " + v + " not in [" + r.min() + ", " + r.max() + "]");
                }
            }
        }
        return errors;
    }

    public static void main(String[] args) throws IllegalAccessException {
        System.out.println(validate(new NewOrder("AAPL", 100, 18_950)));
        System.out.println(validate(new NewOrder(" ", 0, 18_950)));
        System.out.println(validate(new NewOrder("MSFT", 5, 200_000)));
    }
}
Output
[]
[symbol must not be blank, quantity 0 not in [1, 1000000]]
[priceCents 200000 not in [1, 100000]]

getDeclaredFields returns a record's fields in component order, so the error list is stable here. For ordinary classes the order of getDeclaredFields is not specified, and production validators sort or index fields by name. Reflection costs time on every call; libraries read annotations once per class and cache the result.

An annotation processor is a compiler plugin (a class extending javax.annotation.processing.AbstractProcessor) that reads annotations with SOURCE or CLASS retention during compilation and generates new source files or reports errors. MapStruct, Dagger, and Immutables work this way. The public processor API cannot modify existing classes, only add files; Lombok changes classes by calling internal javac APIs instead. Since JDK 23, javac runs processors found on the class path only when asked, for example with -processor, --processor-path, or -proc:full.

Interview points

  • Enum constants are singletons per class loader; compare with ==.
  • Never persist ordinal(); it changes when constants are reordered or inserted.
  • A one-constant enum is a singleton that survives serialization and resists reflective construction.
  • Constant-specific bodies make each constant an anonymous subclass; use getDeclaringClass() for the enum type.
  • EnumSet is one long for up to 64 constants (RegularEnumSet), JumboEnumSet above; EnumMap is an ordinal-indexed array. Both iterate in declaration order.
  • An enum constructor cannot read the enum's own non-constant static fields; build lookup maps in a static block.
  • Retention CLASS is the default and is not visible to reflection; frameworks need RUNTIME.
  • @Inherited applies only to class annotations inherited from superclasses.
  • Repeated annotations are stored in a container; getAnnotation returns null for them and getAnnotationsByType finds them.
  • Annotation processors generate new files at compile time; they cannot change existing classes through the standard API.

Summary

  • Every enum extends java.lang.Enum, is Comparable by ordinal, and gets values() and valueOf(String).
  • Enums can have fields, private constructors, methods, and per-constant bodies.
  • A switch expression over all constants is exhaustive and needs no default; null throws NullPointerException.
  • EnumSet and EnumMap are the fastest and smallest set and map for enum keys.
  • Annotations are declared with @interface, restricted by @Target, kept according to @Retention.
  • getAnnotation, isAnnotationPresent, and getAnnotationsByType read RUNTIME annotations.