Choosing a Bitmask Permission Model in Crystal: Enum Flags vs Integer vs Boolean Struct
Guide to selecting a bit‑mask permission model in Crystal, comparing Enum Flags, raw integers, and boolean structs with trade‑offs, a ready‑to‑run example, and validation steps.
17 Sept 2026, 23:51 UTC

Decision and Constraints
You need to model a set of user permissions for a Crystal 1.x web service. The decision must satisfy:
- Compile‑time safety – invalid combinations should be caught early.
- Ergonomic bitwise operations (|, &, includes?).
- JSON API compatibility – the permission value should serialize/deserialize without extra runtime cost.
- Zero allocation on hot paths (e.g., permission checks in request handlers).
Options Comparison
| Option | Type Safety | Runtime Overhead | JSON Handling | Notes |
|---|---|---|---|---|
Enum Flags (e.g., enum Permission : UInt32; Flags) | High – only defined constants can be used; includes? method provided. | Low – underlying integer, no allocation for bitwise ops. | Requires custom converter or mapping to Array(String); default serializes to integer. | Provides readable names and compile‑time checking. |
Raw Integer Bitmask (e.g., UInt32) | None – any integer value is allowed, risk of invalid bits. | Minimal – direct CPU instructions. | Serializes as number automatically; no extra work. | Fastest and smallest but relies on documentation to avoid misuse. |
Boolean Struct (e.g., struct Permissions; @read : Bool; @write : Bool; @admin : Bool) | Medium – each field is explicit, but adding a flag means editing the struct. | Higher – each Bool is a byte; struct may allocate on stack/heap depending on use. | JSON serializes naturally to object with true/false keys. | Self‑documenting but verbose and inefficient for many flags. |
Trade‑offs
Enum Flags give you type‑safe constants and the includes? convenience method. The downside is that Crystal’s JSON::Serializable does not automatically turn a Flags value into an array of names; you must supply a custom converter or explicitly convert to Array(String) when you need that representation.
Raw Integer is the fastest and uses the least memory, but you lose compile‑time guarantees. A typo like 0b1010 instead of 0b0011 will compile and may cause subtle bugs.
Boolean Struct is the most explicit in JSON (each permission appears as a key) and needs no bitwise logic, yet each added flag expands the struct and increases memory traffic. For services that check permissions thousands of times per second, the extra allocation can be measurable.
Concrete Implementation
Below is a minimal, self‑contained example that shows how to define an enum‑flags permission type, combine flags, test membership, and handle JSON with a custom converter.
require "json"
# Define a flag‑based enum with an explicit underlying type.
enum Permission : UInt32
Flags
READ = 1 << 0 # 0b0001
WRITE = 1 << 1 # 0b0010
ADMIN = 1 << 2 # 0b0100
end
# A simple holder that we will serialize.
struct UserContext
@user_id : Int64
@permissions : Permission
def initialize(@user_id : Int64, @permissions : Permission)
end
# Custom JSON serializer: emit permission as an array of name strings.
def to_json(json : JSON::Builder)
json.object do
json.field "user_id" do
json @user_id
end
json.field "permissions" do
# Convert the flag set to an array of enabled names.
ary = [] of String
Permission.each do |name, value|
if @permissions.includes?(value)
ary << name
end
end
json ary
end
end
end
# Custom JSON deserializer: expect an array of names and rebuild the flag set.
def self.from_json(value : JSON::PullParser)
user_id = nil
perms = Permission::ZERO
value.each do |key|
case key
when "user_id"
user_id = value.pull.value.to_i64
when "permissions"
# Expect an array of strings.
arr = value.pull.read_as(Array(String))
arr.each do |name|
if Permission.has_key?(name)
perms |= Permission[name]
else
raise "Unknown permission: #{name}"
end
end
else
value.skip
end
end
new(user_id, perms)
end
end
# Usage example
ctx = UserContext.new(42, Permission::READ | Permission::WRITE)
# Check a flag – zero allocation, compile‑time safe.
if ctx.permissions.includes?(Permission::READ)
puts "User can read"
end
# Serialize to JSON (produces {"user_id":42,"permissions":["READ","WRITE"]})
json_str = ctx.to_json
puts json_str
# Deserialize back
restored = UserContext.from_json(JSON.parse(json_str))
puts restored.permissions.includes?(Permission::ADMIN) # false
Validation Approach
To confirm that the implementation meets the constraints, perform the following checks in a local Crystal environment (no external services needed):
- Compile and run the snippet with
crystal run permissions.cr. Verify that the printed JSON contains an array of permission names and that the permission checks behave as expected. - Inspect the enum at runtime:
crystal eval "puts Permission::READ.value"should output1, andPermission.flags?should returntrue, confirming theFlagsmixin is present. - For hot‑path allocation, write a small benchmark using Crystal’s
Benchmarkmodule that callsincludes?in a tight loop (e.g., 1_000_000 iterations) and compare the allocation count against a baseline integer bitmask. You should see zero additional allocations for the flag version beyond the loop’s own variables. - Test JSON round‑trip: serialize a
UserContextinstance, deserialize it, and assert that the original and restored permission sets are equal. Any mismatch indicates a problem with the custom converter.
Limitations and Practical Checks
- The
Flagsmixin and the default underlying type (UInt32) are stable from Crystal 0.35 onward, but if you target a pre‑0.35 compiler you must verify the behavior manually. - The custom JSON converter shown allocates temporary strings when building the
Array(String)representation. Avoid calling this conversion inside a request‑handler loop; instead, keep the internalPermissionvalue and only convert when preparing the response. - If you need to send the permission as a single integer (e.g., to a legacy client), you can skip the custom converter and let the enum serialize to its numeric value directly.
- Always validate incoming permission arrays against the known enum keys to prevent injection of undefined bits.
By following the steps above you can decide which permission model fits your service’s safety, performance, and API requirements, and you have a concrete, verifiable implementation for the Enum Flags approach.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.