DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on Edited on

Java Enums and Annotations Explained Simply

For a long time, I treated enums as "a fancy list of constants" and annotations as "those @ things I put above methods because the tutorial said so." Both ideas felt like syntax I had to memorize, not tools I understood.

Then I learned what each one really is. An enum is a full class with a fixed set of objects. An annotation is a label with data that tools can read. And the two even meet in the middle: when you write @Target(ElementType.METHOD), that ElementType is an enum.

In this post, we'll cover both topics: what they are, how the compiler treats them, and when to use them. I've drawn the important pictures in text so you can see what's going on.

Unless I show a full class, the snippets go inside a main method.

Part 1: Enums

The problem enums solve

Imagine an order that can be pending, shipped or delivered. Before enums, people wrote constants like this:

static final int PENDING = 0;
static final int SHIPPED = 1;
static final int DELIVERED = 2;

static void setStatus(int status) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

It works, but nothing protects you. The compiler happily accepts setStatus(42) or setStatus(-7). Printing a status shows 1, which means nothing to a reader. And a typo like setStatus(SHIPED) fails, while setStatus(1) quietly passes.

What is an enum?

An enum (short for "enumeration") is a type with a fixed set of named values. You declare it with the keyword enum:

enum Day {
    MONDAY, TUESDAY, WEDNESDAY
}
Enter fullscreen mode Exit fullscreen mode
Day today = Day.TUESDAY;
System.out.println(today);
Enter fullscreen mode Exit fullscreen mode

Output:

TUESDAY
Enter fullscreen mode Exit fullscreen mode

Now a method that takes a Day accepts only a Day. No other values are possible, and it prints in a readable way. By convention, the constants are written in UPPER_SNAKE_CASE.

Useful built-in methods

Every enum gets some methods for free:

for (Day d : Day.values()) {
    System.out.println(d.ordinal() + " " + d.name());
}

System.out.println(Day.valueOf("TUESDAY") == Day.TUESDAY);
Enter fullscreen mode Exit fullscreen mode

Output:

0 MONDAY
1 TUESDAY
2 WEDNESDAY
true
Enter fullscreen mode Exit fullscreen mode
  • values() returns all constants in the order you declared them.
  • name() returns the constant's name as a String.
  • ordinal() returns its position, starting at 0.
  • valueOf(String) finds a constant by its exact name. If there's no match, it throws an exception:
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant Day.FRIDAY
Enter fullscreen mode Exit fullscreen mode

Enums and switch

Enums fit switch very well. With a switch expression, if you cover every constant, the compiler checks that none is missing:

Day today = Day.TUESDAY;

String type = switch (today) {
    case MONDAY, TUESDAY -> "Early week";
    case WEDNESDAY       -> "Midweek";
};

System.out.println(type);
Enter fullscreen mode Exit fullscreen mode

Output:

Early week
Enter fullscreen mode Exit fullscreen mode

If you later add THURSDAY to the enum, this code stops compiling until you handle it. That's a free safety net.

Enums can have fields, constructors and methods

This is where enums become powerful. Each constant can carry data:

enum CoffeeSize {
    SMALL(250),
    MEDIUM(350),
    LARGE(500);

    private final int ml;

    CoffeeSize(int ml) {
        this.ml = ml;
    }

    int getMl() {
        return ml;
    }
}
Enter fullscreen mode Exit fullscreen mode
for (CoffeeSize s : CoffeeSize.values()) {
    System.out.println(s + " = " + s.getMl() + " ml");
}
Enter fullscreen mode Exit fullscreen mode

Output:

SMALL = 250 ml
MEDIUM = 350 ml
LARGE = 500 ml
Enter fullscreen mode Exit fullscreen mode

The rules:

  • The constants come first, and you end the list with a semicolon ; if anything follows.
  • The constructor is always private (even if you don't write the word). You can never write new CoffeeSize(...).
  • An enum can implement interfaces, but it can't extend another class.

Behind the scenes: an enum is a class

The compiler turns your enum into a real class. Roughly, CoffeeSize becomes this:

   final class CoffeeSize extends java.lang.Enum<CoffeeSize> {

       public static final CoffeeSize SMALL  = new CoffeeSize("SMALL",  0, 250);
       public static final CoffeeSize MEDIUM = new CoffeeSize("MEDIUM", 1, 350);
       public static final CoffeeSize LARGE  = new CoffeeSize("LARGE",  2, 500);

       private CoffeeSize(String name, int ordinal, int ml) { ... }

       public static CoffeeSize[] values()  { ... }
       public static CoffeeSize valueOf(String name) { ... }
   }
Enter fullscreen mode Exit fullscreen mode

You can check this yourself:

System.out.println(CoffeeSize.SMALL.getClass());
System.out.println(CoffeeSize.class.getSuperclass());
Enter fullscreen mode Exit fullscreen mode

Output:

class CoffeeSize
class java.lang.Enum
Enter fullscreen mode Exit fullscreen mode

This picture explains a lot:

  • Each constant is a real object, created once when the class is first loaded, in a static setup, just like the static initialization we saw earlier.
  • The name and ordinal come from the hidden parent class Enum. That's why an enum can't extend anything else: its one parent is already taken.
  • Since the constructor is private and the constants are created only inside the class, no other objects can ever exist. That's why comparing enums with == is safe and the usual choice.

Constants with their own behavior

Each constant can even have its own method body, by declaring an abstract method in the enum and implementing it for every constant:

enum Operation {
    ADD {
        int apply(int a, int b) { return a + b; }
    },
    MULTIPLY {
        int apply(int a, int b) { return a * b; }
    };

    abstract int apply(int a, int b);
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(Operation.ADD.apply(3, 4));
System.out.println(Operation.MULTIPLY.apply(3, 4));
System.out.println(Operation.ADD.getClass().getName());
Enter fullscreen mode Exit fullscreen mode

Output:

7
12
Operation$1
Enter fullscreen mode Exit fullscreen mode

The last line gives away the trick. Each constant with a body is an anonymous class (Operation$1), the same kind we saw in the nested classes post. It's polymorphism, with the constant as the object.

EnumMap and EnumSet

Java has special collections built for enums. They're faster and use less memory than the general ones, and they keep the constants in declaration order:

Map<Day, String> plan = new EnumMap<>(Day.class);
plan.put(Day.WEDNESDAY, "Gym");
plan.put(Day.MONDAY, "Study");

System.out.println(plan);
Enter fullscreen mode Exit fullscreen mode

Output:

{MONDAY=Study, WEDNESDAY=Gym}
Enter fullscreen mode Exit fullscreen mode

Notice that MONDAY came first even though we inserted it second. The map orders by the enum's position.

Part 2: Annotations

What is an annotation?

An annotation is extra information attached to your code, written with an @ sign. On its own, it doesn't change what the code does. It's a label. Other things (the compiler, a tool or a framework) read the label and act on it.

You've already used several:

  • @Override tells the compiler, "this method must override a parent method." If it doesn't, you get an error:
method does not override or implement a method from a supertype
Enter fullscreen mode Exit fullscreen mode
  • @Deprecated marks something as outdated. Using it triggers a warning:
class Legacy {
    @Deprecated
    void oldMethod() { }
}

// elsewhere:
new Legacy().oldMethod();
Enter fullscreen mode Exit fullscreen mode
Note: Demo.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
Enter fullscreen mode Exit fullscreen mode
  • @SuppressWarnings("unchecked") tells the compiler to stay quiet about a certain warning.
  • @FunctionalInterface asks the compiler to check that an interface has exactly one abstract method.

Annotations can be placed on classes, methods, fields, parameters, constructors and more. Some carry data: @SuppressWarnings("unchecked") has a value, and others can have named values like @Author(name = "Asha", year = 2026).

Creating your own annotation

You declare an annotation with @interface. Its "methods" are really the elements (the data it can hold), and they can have default values:

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test {
    String description() default "";
    int priority() default 1;
}
Enter fullscreen mode Exit fullscreen mode

Look at the two annotations on top of it. Those are meta-annotations, which are annotations used on other annotations:

  • @Target says where the annotation may be placed. Here, only on methods. (And ElementType.METHOD is an enum constant, which is where the two topics meet.)
  • @Retention says how long the annotation survives. This is the important one, so let's look at it.

Retention: how long does the label last?

   SOURCE   kept only in your .java file, then thrown away by the compiler
            examples: @Override, @SuppressWarnings

   CLASS    kept inside the .class file, but not available when the program runs
            (this is the DEFAULT if you write nothing)

   RUNTIME  kept in the .class file AND readable while the program runs, using reflection
            examples: @Deprecated, @FunctionalInterface, annotations used by frameworks
Enter fullscreen mode Exit fullscreen mode

If you want code to find your annotation at run time, you must say RUNTIME. Forgetting this is the most common annotation mistake, and we'll see why in a moment.

Reading annotations: a tiny test runner

Annotations become useful when something reads them. Here's a small program that finds every method marked with @Test and runs it. It uses reflection, which is Java's ability to inspect classes while the program is running.

import java.lang.annotation.*;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test {
    String description() default "";
    int priority() default 1;
}

class MyTests {
    @Test(description = "adds numbers")
    public void addTest() {
        System.out.println("addTest ran");
    }

    @Test(priority = 2)
    public void subTest() {
        System.out.println("subTest ran");
    }

    public void helper() {
        System.out.println("helper should not run");
    }
}

public class Runner {
    public static void main(String[] args) throws Exception {
        MyTests tests = new MyTests();

        Method[] methods = MyTests.class.getDeclaredMethods();
        Arrays.sort(methods, Comparator.comparing(Method::getName));

        for (Method m : methods) {
            if (m.isAnnotationPresent(Test.class)) {
                Test t = m.getAnnotation(Test.class);
                System.out.println("Running " + m.getName()
                        + " (priority " + t.priority() + "): " + t.description());
                m.invoke(tests);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Running addTest (priority 1): adds numbers
addTest ran
Running subTest (priority 2): 
subTest ran
Enter fullscreen mode Exit fullscreen mode

The helper method was skipped, because it has no label. (I sorted the methods by name because Java doesn't promise any particular order from getDeclaredMethods().)

Now try deleting the @Retention(RetentionPolicy.RUNTIME) line and run it again. Nothing prints. The default retention is CLASS, so the label is in the file but invisible at run time, and isAnnotationPresent returns false.

This is the heart of how frameworks work. Libraries like JUnit (@Test), Spring (@Component) and many others do exactly this: scan your classes, look for labels, and act on them. The annotation holds the data, and a separate piece of code gives it meaning.

Behind the scenes

An annotation type is a special kind of interface. When you call m.getAnnotation(Test.class), the JVM creates an object (a proxy) that implements your Test interface, and fills it with the values written in the source. So t.priority() is a normal method call that returns the stored number.

   @Test(priority = 2)          you write this on a method
        |
        v   compiler stores the label inside the .class file (if retention allows)
        |
        v   at run time: m.getAnnotation(Test.class)
        |
   +---------------------------------------+
   | proxy object implementing Test        |
   |   priority()    -> 2                  |
   |   description() -> ""   (the default) |
   +---------------------------------------+
Enter fullscreen mode Exit fullscreen mode

There's one more way annotations get used. Some tools run during compilation (called annotation processors) and read labels to generate code. Libraries like Lombok work this way, which is why @Getter can create getters you never wrote.

Annotation elements can only hold a limited set of types: primitives, String, Class, enums, other annotations, and arrays of these.

Which one should I use?

Enum or constants?

  • Use an enum whenever you have a fixed, known set of values: days, order statuses, directions, sizes, roles. You get type safety, readable output and switch checks.
  • Don't use an enum for values that change at run time or come from a database. An enum is fixed when you compile.

Annotation or marker interface? In the interfaces post, we saw marker interfaces like Serializable. Here's the comparison:

  • A marker interface creates a type. You can use it in instanceof and as a parameter type.
  • An annotation carries data, can sit on methods, fields and parameters (not just classes), and can be added without changing the class's inheritance.

Pick the interface when you need the type, and the annotation when you need to attach data or label something that isn't a class.

Which retention? Choose the shortest that works. If only the compiler needs it, use SOURCE. If only tools reading the .class file need it, use CLASS. If your code reads it with reflection, use RUNTIME.

Common mistakes

  1. Saving ordinal() to a file or database. If someone reorders or adds a constant, every saved number now means something else. Store the name (or an explicit code field) instead.
  2. Calling valueOf without handling bad input. An unknown name throws IllegalArgumentException, and a null throws NullPointerException. Check or catch these when the text comes from outside. (A switch on a null enum also throws.)
  3. Using an enum for changing data. If the list of values isn't known when you compile, use a normal class or a collection.
  4. Forgetting @Retention(RetentionPolicy.RUNTIME). Reflection can't see the annotation, and isAnnotationPresent quietly returns false.
  5. Expecting an annotation to do work by itself. A label does nothing unless the compiler, a tool or your own code reads it.
  6. Leaving off @Override or ignoring @Deprecated warnings. Both are free help from the compiler. A tiny typo in a method name creates a new method instead of overriding, and @Override turns that into an error you can see.

Quick recap

  • An enum is a type with a fixed set of named constants, which is safer and more readable than int or String constants.
  • Enums come with values(), valueOf(), name() and ordinal(), and they work nicely with switch.
  • Enums can have fields, constructors and methods. The constructor is private, and constants can even have their own method bodies.
  • Behind the scenes, an enum is a class extending java.lang.Enum, and each constant is a single public static final object created once. That's why == is safe.
  • EnumMap and EnumSet are fast collections made for enums.
  • An annotation is a label with optional data, written with @. It doesn't change behavior by itself.
  • You create one with @interface, and control it with the meta-annotations @Target and @Retention.
  • SOURCE, CLASS and RUNTIME decide how long the label lasts. Only RUNTIME annotations can be read with reflection.
  • Frameworks work by reading annotations and acting on them. An annotation type is a special interface, and the JVM gives you a proxy object when you read it.

Try it yourself

Here's a small practice idea: build an OrderStatus enum with PENDING, SHIPPED and DELIVERED. Give each a label field (like "Order received") and a method canCancel() that returns true only for PENDING. Then loop over values() and print each one. After that, use an EnumMap to count how many orders are in each status.

For a bonus experiment, extend the test runner from this post. Add an @Ignore annotation, and make the runner skip any @Test method that also has @Ignore. Then try removing @Retention from @Ignore and see what changes.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

That wraps up this series on abstraction, nested classes, lambdas, enums and annotations.

Top comments (0)