What Enums in C, Java, Rust, Go and TypeScript Say About Type Design
Compare how seven languages define enums, from named integers to tagged unions and runtime objects, and learn what that means for TypeScript modeling.
This article is published in English.
Sources: Enums Were Never Just Constants
Almost every mainstream language has something called an enum, and almost every one of them means something different by it. In one language it is a thin label over an integer, in another it is a class with methods, in a third it is a union of differently shaped data, and in TypeScript it is one of the few type-level constructs that leaves real JavaScript behind. Walking through C, C++, Java, Rust, Go, TypeScript and Odin side by side makes those differences concrete, and it gives you a sharper vocabulary for deciding what your own TypeScript code should use when a value can only be one of a few things.
One word, several separate problems
The textbook picture is simple. You list a handful of names, and each name stands for one of the allowed values:
Red
Green
Blue
That looks like a universal concept, yet the moment you compare implementations it splits apart:
- C treats an enum mostly as a named group of integer constants.
- C++ keeps that model but adds
enum class, which brings scoping and stricter typing. - Java makes each enum constant a real object of an enum class, complete with fields, constructors, methods and even per-constant class bodies.
- Rust lets a single enum type hold variants that carry entirely different data.
- Go has no
enumkeyword; it offers constants and theiotaidentifier instead. - TypeScript enums are compiled into JavaScript objects that exist at runtime.
- Odin keeps enums deliberately plain and hands the neighbouring jobs to other features, such as bit sets and enum-indexed arrays.
The reason there is no single definition is that "enum" is really a bundle of problems that happen to share a keyword. A language designer has to decide how much of the following the construct should own:
- giving names to a fixed set of values
- introducing a distinct type rather than reusing an existing one
- attaching data to individual values
- representing several possible states of a value
- letting the compiler prove that every case has been handled
- representing combinations of flags compactly
- deciding what, if anything, survives into the running program
Each language picks a different subset, and the choices reveal a lot about its priorities.
C: readable names for integers
The traditional C enum is a convenience for naming integer constants. A status type might be declared like this:
enum Status {
STATUS_OK,
STATUS_ERROR,
STATUS_PENDING
};
Under the hood the three names simply correspond to consecutive integers starting at zero:
0
1
2
That may sound trivial, but it solves a genuine readability problem. Picture a comparison against a bare number buried in configuration logic:
if (status == 2) {
...
}
Nothing in that line tells a reviewer what 2 stands for. The same check written against the named constant documents itself:
if (status == STATUS_PENDING) {
...
}
This is the oldest justification for enums: they replace magic numbers with names that carry intent. They also pair naturally with switch, where each case reads as a statement about the domain rather than about arithmetic:
switch (status) {
case STATUS_OK:
...
break;
The remaining cases follow the same pattern:
case STATUS_ERROR:
...
break; case STATUS_PENDING:
...
break;
}
Because the values are ordinary, predictable integers, C enums sit comfortably in low-level code: they map onto hardware registers, protocol fields and file formats without surprises.
The trade-off is weak enforcement. C does not treat an enum as a closed set the way Rust does. Enumeration constants behave like integers, an enum variable can usually be assigned any integer without complaint, and the compiler has no obligation to flag a missing case. Many compilers offer warnings for unhandled enum values in a switch, but that is tooling courtesy rather than a language guarantee. C23 did add syntax for declaring a fixed underlying type, which narrows a gap C++ closed years earlier.
The C philosophy can be summed up as:
Give integers better names and keep the machinery around them to a minimum.
For systems programming, that minimalism is frequently exactly what you want.
C++: the same idea with firmer boundaries
C++ inherited C-style enums and later introduced a stricter form, the scoped enumeration:
enum class Status {
Ok,
Error,
Pending
};
The change is not just syntactic sugar. Enumerators of a scoped enum must be qualified with the type name:
Status::Ok
instead of appearing as a bare identifier in the surrounding scope:
Ok
Just as important, a scoped enum does not convert implicitly to an integer. You cannot accidentally compare a Status with an unrelated Color or pass it where an int is expected without an explicit cast, which removes a whole class of silent mix-ups.
C++ also lets you choose the storage type explicitly:
enum class Status : uint8_t {
Ok,
Error,
Pending
};
That matters whenever layout is part of the contract, for example in network packets or structures shared with C code. The language distinguishes unscoped enums from scoped ones declared with enum class or enum struct, and both forms accept a fixed underlying type.
The pattern worth noticing is that C++ does not redefine what an enum is. It adds control over three things around it:
- how names are scoped
- how strictly the type is separated from integers
- how the value is represented in memory
At its core it is still a list of named values; the programmer simply gets to draw sharper lines around it.
Java: enums as part of the object model
Java moves much further away from integers. The declaration looks familiar:
enum Status {
OK,
ERROR,
PENDING
}
Yet OK, ERROR and PENDING are not numeric aliases. According to the Java Language Specification, an enum is a special, constrained form of class whose instances are all declared up front by name. Each constant is an object, created once when the class is initialized, and because it is an object it can hold state and behavior.
Constants that carry data
A constructor argument can attach a value to every constant. Here each planet records its mass:
enum Planet {
EARTH(5.97),
MARS(0.642);
The field and the constructor that populates it complete the declaration:
final double mass; Planet(double mass) {
this.mass = mass;
}
}
Every constant now travels with its own data, and code that needs a planet's mass reads a field instead of consulting a separate lookup table that could drift out of sync.
Constants that carry behavior
Enums can also declare methods, and each constant can supply its own implementation. The following operation type overrides an abstract apply method per constant:
enum Operation {
ADD {
@Override
int apply(int a, int b) {
return a + b;
}
},
The second constant and the abstract declaration look like this:
MULTIPLY {
@Override
int apply(int a, int b) {
return a * b;
}
}; abstract int apply(int a, int b);
}
At this point the enum is visibly a class, and that is intentional. Java folds enums into its object-oriented model, so a constant can have fields, constructor arguments, shared methods and constant-specific overrides. A constant with its own class body is compiled as an anonymous subclass of the enum type, and in recent Java versions the specification describes such an enum class as implicitly sealed, since the set of subclasses is fixed by the declaration.
Java's position, then, is roughly:
If a value comes from a fixed set, why restrict it to an integer when it could be an object with its own behavior?
That makes Java enums a strong fit for state machines, parsers, operator tables with precedence, and strategy-style dispatch where each option knows how to perform its own work.
When the shape of the data depends on the variant
Java enums shine when every constant has the same kind of predefined information. A different problem appears when each alternative needs a different structure.
Consider messages in a chat protocol:
TextMessage
ImageMessage
VideoMessage
A text message holds a string, an image message holds image bytes and metadata, and a video message holds something else again. What varies here is not a field value but the layout of the data itself. A Java-style enum, where every constant shares one set of fields, fits poorly. This is the problem Rust's enums are designed for.
Rust: enums as tagged unions
In Rust, the variants of one enum can each have their own payload:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
Quit carries nothing, Move has named fields, Write wraps a String, and ChangeColor holds three bytes. Constructing a value picks a variant and supplies its data:
let message = Message::Move { x: 10, y: 20 };
A different variant of the same type looks like this:
let message = Message::Write(String::from("Hello"));
Both expressions produce a Message, yet the information inside them has a completely different shape. This construct is what type theory calls a sum type, and what systems programmers often call a tagged union: a tag records which variant is active, and the payload holds that variant's data. The type describes a closed set of alternatives:
Message
├── Quit
├── Move { x, y }
├── Write(String)
└── ChangeColor(r, g, b)
Exhaustive matching
The compiler knows every variant, and match puts that knowledge to work. The first arm handles Quit:
match message {
Message::Quit => {
println!("Quit");
}
The remaining arms destructure the payload of each other variant and bind its fields to local names:
Message::Move { x, y } => {
println!("Move to {}, {}", x, y);
} Message::Write(text) => {
println!("{}", text);
} Message::ChangeColor(r, g, b) => {
println!("RGB: {}, {}, {}", r, g, b);
}
}
If you delete one of those arms, the program no longer compiles; Rust rejects a non-exhaustive match. The Rust documentation presents enums and pattern matching together for exactly this reason. Adding a new variant later turns every incomplete match in the codebase into a compile error that points you to the places needing an update.
That is the essential difference from a C enum. A C enum answers:
Which of the named constants does this number correspond to?
A Rust enum answers:
Which variant is active right now, and what payload comes with it?
The second question covers a far larger space of designs.
Making invalid states unrepresentable
The practical payoff is easiest to see with outcomes. An operation that can succeed or fail can be modeled as a type with two variants:
Result
├── Success(value)
└── Error(error)
A value that may or may not be present gets the same treatment:
Option
├── Some(value)
└── None
Rust's standard library ships both of these as Result and Option, and they are ordinary enums. Compare that with the loose-field approach common in many codebases:
value = null
hasValue = false
error = ...
With separate fields, nothing stops hasValue from being true while value is null, or an error and a value from being set at the same time. Every consumer has to remember the unwritten rules. With a sum type, those combinations cannot be constructed at all, because the only way to build a value is through one of the declared variants.
That captures one of the deepest purposes of enum design:
An enum can encode rules about the shape of your data directly in the type system.
Once the rules live in the type, the compiler can find the missing cases before anything runs. TypeScript developers get a close equivalent through discriminated unions, where a shared literal field such as kind plays the role of the tag and narrowing plus a never check provides exhaustiveness.
Go: no enum keyword at all
After Rust, Go's approach can feel like a step back in time. There is no enum keyword. The idiom is a block of constants driven by iota:
const (
StatusOK = iota
StatusError
StatusPending
)
Inside a constant declaration, iota evaluates to the index of the current constant specification, starting at zero, and it can appear inside expressions. When a line in the block omits its expression, Go repeats the previous one, so the following:
const (
A = iota
B
C
)
yields:
A = 0
B = 1
C = 2
Because the implicit repetition copies the whole expression, iota can do more than count. Shifting by it generates powers of two:
const (
Read = 1 << iota
Write
Execute
)
The resulting values are:
Read = 1
Write = 2
Execute = 4
Distinct bits make these constants natural flags: Read | Write combines permissions, and a bitwise AND tests for one. You can also introduce a named type so the constants are not just loose integers:
type Permission uint8
and then declare the constants with that type:
const (
Read Permission = 1 << iota
Write
Execute
)
Only Read spells out Permission, but the repeated expression carries the type along, so Write and Execute are Permission values as well. The Go specification distinguishes typed from untyped constants, and a named type gives you some protection against mixing unrelated values. It still does not create the kind of enum identity found in languages with a dedicated construct.
That is characteristic of Go: rather than adding a feature, it provides a flexible constant system and leaves the higher-level abstraction to the programmer. The cost is that the language never formally ties
these constants
to
this closed set of variants
in the way Rust or Java does. Any value of the underlying type can be converted into a Permission, and the compiler does not check that a switch over these constants is exhaustive. Teams that want that guarantee usually add a linter or a code generator on top.
TypeScript: an enum that exists at runtime
TypeScript is mostly a static type layer over JavaScript. A type alias such as this one:
type User = {
name: string;
};
leaves no trace in the emitted JavaScript; it is erased during compilation. Enums break that rule:
enum Direction {
Up,
Down,
Left,
Right
}
A regular TypeScript enum compiles to a real JavaScript object, and the TypeScript handbook documents this runtime behavior explicitly. For numeric enums, the generated object includes both forward and reverse mappings, so you can go from name to number and from number back to name:
Direction.Up -> 0
Direction[0] -> "Up"
That surprises developers who assume everything TypeScript adds is compile-time only. It also has practical consequences: the enum contributes code to your bundle, and iterating over a numeric enum with Object.keys returns the numeric keys as strings alongside the names.
String enums assign explicit string values:
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
They do not get reverse mappings, and their values are much easier to read in logs, network payloads and debugger output, since "UP" explains itself where 0 does not. TypeScript also supports computed members and const enum, which is inlined at use sites and removed from the output. Be aware that const enum interacts badly with tools that compile one file at a time, which is why many projects avoid it.
The object-plus-as-const alternative
The TypeScript documentation also shows a lighter pattern built from a plain object and an as const assertion:
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
This is ordinary JavaScript that any tool understands, while as const gives TypeScript literal types for every property. A matching union type can be derived with (typeof Direction)[keyof typeof Direction]. The pattern has also become more relevant as runtimes gained the ability to strip types directly: that approach only removes annotations, and enums, which need real code generation, do not fit it. Check the current documentation of your runtime and compiler for the exact rules; the article on what Node.js native TypeScript support actually does covers that side in detail.
The underlying point is that TypeScript's enum design is shaped by its host. Java could define enums as a first-class runtime feature from scratch. TypeScript has to live alongside JavaScript, which has no enum of its own. Its version of the question becomes:
What should a type-level feature turn into when the runtime underneath is JavaScript?
There is no clean answer, which is why TypeScript ended up with several overlapping options.
Odin: a simple enum plus orthogonal tools
Odin takes yet another route. Its enums stay close to the classic idea of a named set of values, and instead of stretching the enum into an object hierarchy or an algebraic data type, the language supplies separate features for neighbouring problems: distinct types, enumerated arrays that are indexed by an enum, unions, and bit sets whose element type can be an enum.
A plain enum describes compass directions:
Direction :: enum {
North,
East,
South,
West,
}
A bit set over that enum then represents any combination of directions:
Direction_Set :: bit_set[Direction]
The design principle is a division of labour:
- a fixed set of values is an enum
- a collection of those values is a bit set
- data associated with each value lives in an array indexed by the enum
- alternatives with different data layouts are a union
Because each tool does one job, you can reason about each in isolation, and none of them has to carry the weight of all the others. Compare that with Go, where flags are built by hand with shifts, or with Rust, where the enum itself covers both simple lists and tagged unions.
No single language got enums right
It is tempting to rank these designs, but they are answering different questions. A compact comparison:
- C: named integer constants, optimized for simplicity, readability and low-level compatibility.
- C++: the same foundation with optional scoping, stricter typing and an explicit underlying type.
- Java: a fixed set of instances of an enum class, able to hold fields, constructors, methods and per-constant behavior.
- Rust: a closed set of variants, each able to carry its own data, with exhaustive pattern matching enforced by the compiler.
- Go: no dedicated construct; constants and
iotagenerate sequential or computed values. - TypeScript: enums that exist as JavaScript objects at runtime, unlike most TypeScript type features, alongside erasable alternatives.
- Odin: simple enums complemented by bit sets, enumerated arrays and unions for related tasks.
None of these is an upgraded version of the one before it. Each reflects what its designers wanted the feature to be responsible for.
What enum design reveals about a language
Enums are small enough to learn in an afternoon, yet they expose the philosophy of the language around them. Framed as the question each language seems to ask:
- C: how can this be represented efficiently with simple primitives?
- C++: how do we keep close-to-the-metal control and still add safer abstractions?
- Java: what if every member of a closed set were a full object with its own methods?
- Rust: can one type spell out all legal states along with the payload of each?
- Go: is a dedicated feature needed at all?
- TypeScript: what should a compile-time feature become once it has to run as plain JavaScript?
- Odin: can the core construct stay simple while orthogonal features handle related needs?
Because the languages are optimizing for different things, the enum never converged into one idea.
Enums are modeling decisions, not syntax
It is easy to think of an enum as a piece of syntax:
enum {
A,
B,
C
}
The syntax is the least interesting part. The real question is what you want the type system to guarantee about the values you listed:
A, B, C
Working through that question means answering several smaller ones:
- Are these merely names for numbers?
- Does each value need associated data?
- Does each variant need its own behavior?
- Should the compiler know these are the only valid states and enforce exhaustive handling?
- Do you need compact bit flags?
- Must the values exist at runtime, for iteration or validation?
- How should they serialize over the wire or into storage?
Different answers lead to different designs, and the consequences reach well past syntax: memory layout, type safety, what the compiler can analyze, runtime cost, pattern matching, API shape, serialization format, and ultimately how the team thinks about its data.
Sometimes the right enum is no enum
This has direct consequences for everyday TypeScript. A language offers an enum because a certain problem exists, but that does not mean every fixed set of values should become one. Depending on the situation:
- a union of string literals is often clearer and costs nothing at runtime
- a plain object with
as constgives you runtime values and types without special compilation - a dedicated class makes sense when each option carries substantial behavior
- a bit set or flags integer is the right tool for combinations
- a discriminated union is what you actually need when variants carry different data
- sometimes three well-named constants are all the structure the problem deserves
Good type design starts from the data and its invariants, not from whatever keyword the language happens to provide.
Key takeaways
- "Enum" bundles several independent concerns: naming, distinct typing, attached data, variant shapes, exhaustiveness, flags and runtime presence.
- C and C++ center on integers and representation; Java centers on objects; Rust centers on sum types with compiler-checked matching.
- Go and Odin show two opposite ways to avoid an all-purpose enum: build it yourself from constants, or split the job across focused features.
- TypeScript enums are real runtime objects, which affects bundles, iteration and compatibility with type-stripping tools; literal unions and
as constobjects are often the simpler choice. - Before reaching for an enum, decide which guarantees you need. The construct you pick should follow from those guarantees, not the other way around.