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) { /* ... */ }
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
}
Day today = Day.TUESDAY;
System.out.println(today);
Output:
TUESDAY
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);
Output:
0 MONDAY
1 TUESDAY
2 WEDNESDAY
true
-
values()returns all constants in the order you declared them. -
name()returns the constant's name as aString. -
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
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);
Output:
Early week
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;
}
}
for (CoffeeSize s : CoffeeSize.values()) {
System.out.println(s + " = " + s.getMl() + " ml");
}
Output:
SMALL = 250 ml
MEDIUM = 350 ml
LARGE = 500 ml
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) { ... }
}
You can check this yourself:
System.out.println(CoffeeSize.SMALL.getClass());
System.out.println(CoffeeSize.class.getSuperclass());
Output:
class CoffeeSize
class java.lang.Enum
This picture explains a lot:
- Each constant is a real object, created once when the class is first loaded, in a
staticsetup, just like the static initialization we saw earlier. - The
nameandordinalcome from the hidden parent classEnum. 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);
}
System.out.println(Operation.ADD.apply(3, 4));
System.out.println(Operation.MULTIPLY.apply(3, 4));
System.out.println(Operation.ADD.getClass().getName());
Output:
7
12
Operation$1
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);
Output:
{MONDAY=Study, WEDNESDAY=Gym}
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:
-
@Overridetells 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
-
@Deprecatedmarks something as outdated. Using it triggers a warning:
class Legacy {
@Deprecated
void oldMethod() { }
}
// elsewhere:
new Legacy().oldMethod();
Note: Demo.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
-
@SuppressWarnings("unchecked")tells the compiler to stay quiet about a certain warning. -
@FunctionalInterfaceasks 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;
}
Look at the two annotations on top of it. Those are meta-annotations, which are annotations used on other annotations:
-
@Targetsays where the annotation may be placed. Here, only on methods. (AndElementType.METHODis an enum constant, which is where the two topics meet.) -
@Retentionsays 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
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);
}
}
}
}
Output:
Running addTest (priority 1): adds numbers
addTest ran
Running subTest (priority 2):
subTest ran
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) |
+---------------------------------------+
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
switchchecks. - 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
instanceofand 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
-
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. -
Calling
valueOfwithout handling bad input. An unknown name throwsIllegalArgumentException, and anullthrowsNullPointerException. Check or catch these when the text comes from outside. (Aswitchon anullenum also throws.) - Using an enum for changing data. If the list of values isn't known when you compile, use a normal class or a collection.
-
Forgetting
@Retention(RetentionPolicy.RUNTIME). Reflection can't see the annotation, andisAnnotationPresentquietly returnsfalse. - Expecting an annotation to do work by itself. A label does nothing unless the compiler, a tool or your own code reads it.
-
Leaving off
@Overrideor ignoring@Deprecatedwarnings. Both are free help from the compiler. A tiny typo in a method name creates a new method instead of overriding, and@Overrideturns 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
intorStringconstants. - Enums come with
values(),valueOf(),name()andordinal(), and they work nicely withswitch. - 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 singlepublic static finalobject created once. That's why==is safe. -
EnumMapandEnumSetare 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@Targetand@Retention. -
SOURCE,CLASSandRUNTIMEdecide how long the label lasts. OnlyRUNTIMEannotations 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)