Today I want to take a look at a piece of C++ code that Copilot generated for me recently. The code here will not be 1:1 the same because originally it was inserted as a part of the bigger code base - I changed the names and simplified it to focus on the key topic, but the overall idea is exactly the same.
At first glance, the generated code looked perfectly reasonable. In fact, it even compiled. The problem is that it also contained a subtle semantic trap that is very easy to miss if you don't stop and think carefully about what the code is actually meant to do.
The code started with an enum representing permissions where the values are bitmasks:
enum class Permission
{
kNone = 0,
kRead = 1 << 0,
kWrite = 1 << 1,
kExecute = 1 << 2
};
And then Copilot generated an associated stream operator to print the enum value using std::cout:
std::ostream& operator<<(std::ostream& os, const Permission p)
{
switch (p) {
case Permission::kNone:
return os << "None";
case Permission::kRead:
return os << "Read";
case Permission::kWrite:
return os << "Write";
case Permission::kExecute:
return os << "Execute";
default:
return os << "UNKNOWN PERMISSION";
}
}
So far, everything still looks fine. The next step overloads the bit or operator for the Permission enum:
Permission operator|(const Permission lhs, const Permission rhs)
{
return static_cast<Permission>(static_cast<int>(lhs) | static_cast<int>(rhs));
}
Nothing immediately suspicious here either, but the things start to look interesting because Copilot generated also a function that, according to some complex logic, returns combinations of permissions in form of:
Permission GetPermissions()
{
return Permission::A | Permission::B;
}
This is where things start getting tricky. The code is not obviously wrong because representing flags as bitmasks is a completely valid technique, but let's test this with a simple example:
Permission GetPermissions()
{
return Permission::kNone | Permission::kWrite;
}
int main()
{
const Permission p = GetPermissions();
std::cout << "Current permissions: " << p;
}
This works perfectly fine because:
00000000 | 00000010 = 00000010
And 00000010 corresponds exactly to Permission::kWrite, so the output becomes:
Current permissions: Write
However, when the function reaches a path like this:
Permission GetPermissions()
{
return Permission::kRead | Permission::kWrite;
}
the output suddenly changes to:
Current permissions: UNKNOWN PERMISSION
How is this possible that although all enum values of the Permission enum are covered in switch/case statement, after getting the object p of type Permission and printing it, no valid value of the enum is displayed?
The answer is: the value returned from GetPermissions() is simply not represented by any enum field. Take a look:
Permission::kRead = 00000001
Permission::kWrite = 00000010
Their bitwise or produces:
00000011
But there is no enum field equal to 00000011 and C++ does not forbid such value from existing inside an enum object.
The key problem
This reveals a deeper conceptual conflict:
- enums are designed to represent one value from a closed set
- bitmasks are designed to represent combinations of values
These are fundamentally different concepts and without writing a logic which actually enforces the rules of how these 2 contradicting worlds co-exist, we end up with a subtle bug hidden behind a clean API. Of course, the solution is not to keep extending the enum with values like:
kReadWrite
kWriteExecute
kReadExecute
because this does not scale. With more flags, the number of combinations grows exponentially. Let's look at how to fix the issue properly.
AI is powerful. Snippets are instant.
Stop prompting for the same patterns repeatedly. Get almost 100 free VS Code snippets for C++, Python, CMake and Bazel from piko::snippets GitHub repository.
Don't lie with the function signature
Using enums to represent individual bitmask values is perfectly fine. It improves the usage and the readability because you no longer need to remember whether "read" permission is equal to 1, 2, or 16. But the moment you start combining those values, the result is no longer guaranteed to be a valid Permission enum value, so the API should reflect that reality.
The first step is changing the overloaded | operator - instead of pretending that the result is still a Permission, return an integer mask explicitly:
int operator|(const Permission lhs, const Permission rhs)
{
return static_cast<int>(lhs) | static_cast<int>(rhs);
}
Now the operator no longer falsely suggests that combining two permissions produces another valid enum field. Similarly, any function returning combined permissions should return an integer mask instead of Permission:
int GetPermissions()
{
return Permission::kRead | Permission::kWrite;
}
int main()
{
const int p = GetPermissions();
std::cout << "Current permissions: " << p;
}
This now produces:
Current permissions: 3
The value is no longer pretending to be a single enum value. It is simply a bitmask containing multiple flags what is clearily communicated by the API now. If you want, you can print it in a nicer form by using a helper function - here is an example of a simple one:
void PrintPermissions(const int p)
{
if (p & static_cast<int>(Permission::kRead)) {
std::cout << "Read";
}
if (p & static_cast<int>(Permission::kWrite)) {
std::cout << "Write";
}
if (p & static_cast<int>(Permission::kExecute)) {
std::cout << "Execute";
}
std::cout << std::endl;
}
Now the output becomes:
Current permissions: ReadWrite
Top comments (1)
I don't write C++ — I'm a physical therapist who builds hospital tools with AI — but "compiled cleanly, semantically wrong" is the exact trap I live in, just wearing different clothes. Mine is a health check that returns a cheerful 200 while the database behind it is dead. The compiler said yes; reality said no. Same gap you found, one layer up.
What I love here is that the real fix isn't "make the AI smarter" — it's honest types. The enum was lying: it promised "one value from a closed set," then handed back a combination that wasn't in the set. Returning int is the type finally telling the truth about what it is. I did the same thing this week without having your words for it: my check stopped returning a label ("ok") and started returning a receipt — what ran, when, and an explicit field for "no degradation." The type stopped pretending.
The part that scares me is that AI produces the confident-looking version by default. It hands you the enum operator that compiles, not the int one that's honest, because the first reads cleaner. Fluency isn't correctness — the code that reads best is often the one quietly hiding the closed-set assumption. Filing this away. Thanks for writing it.