
TypeScript Enum Quirks: The WTF Moments That Made Me Question Everything
Published on 13th July 2025
TypeScript is supposed to make JavaScript more predictable and safe, right? Well, mostly. But every now and then, you run into something that makes you stare at your screen thinking "wait, what?" For me, one of those moments was discovering the bizarre world of TypeScript enum behavior.
If you've ever worked with TypeScript enums, you might have encountered some head-scratching moments. Why do string enums behave differently from number enums? Why can't I just pass a string literal to a function expecting a string enum? And what's this thing about excess property checks?
Let's dive into these "WTF" moments and explore why they exist, what they tell us about TypeScript's design philosophy, and why many developers are abandoning enums altogether.
The Tale of Two Enums
Let's start with the most confusing aspect of TypeScript enums: the stark difference between string and number enums.
String Enums: The Strict Ones
Consider this simple string enum:
enum Colors {
Green = 'green',
Orange = 'orange',
Red = 'red',
}
function isTheBestColor(color: Colors): boolean {
return color === Colors.Green
}
Now, here's where it gets weird:
// This works fine
isTheBestColor(Colors.Green) // ✅ Works
// This throws a type error
isTheBestColor('green') // ❌ Type error!
Wait, what? The string "green" is literally the same value as Colors.Green, but TypeScript refuses to accept it. This means you have to import the enum everywhere you want to use it, even when you know the exact string value.
Number Enums: The Permissive Ones
Now let's look at number enums:
enum Status {
Pending = 0,
Approved = 1,
Declined = 2,
}
function validateStatus(status: Status): boolean {
return status === Status.Approved
}
Here's where it gets even weirder:
// This works fine
validateStatus(Status.Approved) // ✅ Works
// This ALSO works fine - no type error!
validateStatus(1) // ✅ Works (1 is the value of Status.Approved)
// But this throws a type error
validateStatus(5) // ❌ Type error (5 is not a valid enum value)
So number enums allow you to pass raw numbers that match the enum values, but string enums don't allow raw strings. This inconsistency is... frustrating.
Why Does This Happen?
The honest answer is that nobody really knows the complete reasoning behind this design decision. Even TypeScript experts often shrug and say "it's just how it works." But here's what we can piece together:
The behavior is definitely intentional. The TypeScript compiler knows the exact values at compile time, so it's making a conscious choice to allow raw numbers but not raw strings.
One theory is that it relates to JavaScript's type coercion and how numbers are handled differently from strings in the runtime. Another possibility is that it's simply an arbitrary design decision made early in TypeScript's development that has stuck around for backward compatibility.
What we do know is that this inconsistency has led to countless "WTF" moments for developers.
The Excess Property Check Mystery
Here's another TypeScript behavior that'll make you question reality:
interface Person {
firstName: string
age: number
}
function greetPerson(person: Person) {
console.log(`Hello, ${person.firstName}!`)
}
// This throws a type error
greetPerson({
firstName: 'Alice',
age: 30,
extraProp: 'hello', // ❌ Object literal may only specify known properties
})
// But this works fine!
const alice = {
firstName: 'Alice',
age: 30,
extraProp: 'hello',
}
greetPerson(alice) // ✅ Works perfectly
// And this also works!
greetPerson({
firstName: 'Bob',
age: 25,
...{ extraProp: 'hello' },
}) // ✅ Works too
TypeScript gets angry when you pass an object literal directly with extra properties, but it's perfectly fine with the same object if you assign it to a variable first or use spread syntax. This behavior is called "excess property checking" and it only applies to fresh object literals.
The reasoning here is actually more sensible than the enum situation. TypeScript assumes that if you're creating an object literal inline, you probably didn't mean to include those extra properties. It's trying to catch typos and mistakes. But if you explicitly assign to a variable or use spread syntax, TypeScript assumes you know what you're doing.
The filter(Boolean) Gotcha
Here's another one that trips up developers:
const numbers = [1, undefined, 3, undefined, 5]
// At runtime, this works perfectly and gives us [1, 3, 5]
const filtered = numbers.filter(Boolean)
// But TypeScript still thinks the result is (number | undefined)[]
// instead of number[]
TypeScript doesn't understand that filter(Boolean) removes falsy values and narrows the type. From the compiler's perspective, this makes sense - it's unusual for a compiler to special-case specific function calls and mutate return types based on the operation.
The {} Type Confusion
Here's a fun one:
const value: {} = 'hello' // ✅ Works
const value2: {} = 42 // ✅ Works
const value3: {} = true // ✅ Works
const value4: {} = null // ❌ Type error
const value5: {} = undefined // ❌ Type error
The {} type accepts literally everything except null and undefined. This is counterintuitive because you'd expect it to only accept empty objects. The reasoning is that null and undefined throw errors when you try to access properties on them, while all other values (including primitives) have prototypes that are objects.
The Array Mutation Bug
Here's one that some consider a legitimate compiler bug:
const foo: string[] = ['a', 'b']
function bar(arr: (string | number)[]) {
arr.push(3) // Adding a number to the array
}
bar(foo) // ✅ No type error at call site
// Now foo is actually ["a", "b", 3] at runtime
// But TypeScript still thinks it's string[]
The function bar accepts an array that can contain strings or numbers, and it adds a number to it. When you pass a string[] to this function, TypeScript doesn't complain, even though the function will mutate the array in a way that breaks the type contract.
Some argue this is a fundamental flaw in TypeScript's type system, while others say it's a necessary compromise to maintain compatibility with JavaScript's dynamic nature.
TypeScript's Design Philosophy
All of these quirks stem from TypeScript's core design philosophy: it aims to add type safety to JavaScript while remaining as compatible as possible with the existing JavaScript ecosystem.
This means making "arbitrary decisions" about how the type system should work. The concept of structural typing (or "duck typing") is central to TypeScript - if something "looks like a duck and quacks like a duck," it's considered a duck, regardless of its explicit type declaration.
This philosophy generally works well and allows TypeScript to integrate smoothly with existing JavaScript code. But it also leads to some behaviors that feel inconsistent or counterintuitive.
Why Developers Are Abandoning Enums
Given all these quirks, it's no surprise that many developers are moving away from enums entirely. Here's the alternative approach that's gaining popularity:
Instead of:
enum Colors {
Green = 'green',
Orange = 'orange',
Red = 'red',
}
Use:
const Colors = {
Green: 'green',
Orange: 'orange',
Red: 'red',
} as const
type Colors = (typeof Colors)[keyof typeof Colors]
This approach has several advantages:
1. Runtime Visibility
You can see exactly what JavaScript code will be generated. Enums get compiled into runtime objects, which might not be what you expect.
2. Bundle Size
Enums can bloat your JavaScript bundle because they generate runtime code. The as const approach creates no runtime overhead.
3. Developer Experience
You can use the literal values directly without importing the enum:
// With enums, you need to import
import { Colors } from './colors'
isTheBestColor(Colors.Green)
// With as const, you can use the literal
isTheBestColor('green') // Works fine!
4. No Foot Guns
Enums with implicit number values can cause bugs if you reorder the enum members:
enum Status {
Pending, // 0
Approved, // 1
Declined, // 2
}
// If you later reorder to:
enum Status {
Approved, // 0 (was 1!)
Pending, // 1 (was 0!)
Declined, // 2
}
Any stored data with these numeric values will now mean something different, potentially causing serious bugs.
Embracing the Quirks
While these behaviors can be frustrating, they're part of TypeScript's charm (or curse, depending on your perspective). Understanding why they exist helps you work with them rather than against them.
The key is remembering that TypeScript is trying to balance type safety with JavaScript compatibility. Sometimes this leads to behaviors that feel weird, but they usually make sense when you understand the broader context.
Conclusion
TypeScript enums are a perfect example of how language design is full of tradeoffs. The inconsistencies between string and number enums, the mysterious excess property checks, and the various other quirks all stem from TypeScript's attempt to add static typing to a fundamentally dynamic language.
While these behaviors can be confusing, they're not necessarily wrong - they're just different from what you might expect. The growing trend toward as const objects shows that the developer community is finding its own solutions to these quirks.
Whether you embrace enums or abandon them, understanding their behavior will make you a better TypeScript developer. And who knows? Maybe someday the TypeScript team will surprise us all with a "TypeScript 2.0" that fixes these quirks. Until then, we'll just have to live with the delightful weirdness of TypeScript enums.
Have you encountered any other TypeScript quirks that made you question reality? The type system is full of surprises, and sometimes the best way to understand them is to share our collective confusion and figure them out together.
Reference
This blog post is based on a fascinating discussion between Trash (Chris Batista), Prime, Casey, and TJ about TypeScript quirks. You can watch the full conversation here: WTF TypeScript - YouTube