The Old Way: Dynamic Magic and Its Hidden Costs
Years ago, in ASP.NET MVC and Razor, we had a seductively quick tool for passing state to the UI: ViewBag.
// Controller
ViewBag.Role = "Admin";
ViewBag.CanEdit = true;
ViewBag.Theme = "Dark";
ViewBag.FeatureFlags = new[] { "beta_dashboard", "export_csv" };
And in the Razor view:
@if(ViewBag.CanEdit) {
<button>Edit</button>
}
@if(ViewBag.Role == "Admin") {
<admin-panel />
}
It was fast. It was flexible. It got the job done on Friday at 5 PM.
But the lurking problems went deeper than most developers realized:
The obvious problems:
· String-based keys with no contracts
· Typos silently failing at runtime (ViewBag.CanEdit vs ViewBag.Canedit)
· No autocomplete, no IntelliSense
· Refactoring meant "find and pray"
The deeper architectural wounds:
· Truth multiplicity: The string "Admin" existed in controllers, views, partials, and JavaScript files — with no single source of truth
· Impossible static analysis: No tool could trace the flow of ViewBag.IsAdmin from assignment to consumption
· Stringly-typed death: "admin" vs "Admin" vs "ADMIN" — which one is correct? All of them, until one isn't
· Ghost properties: ViewBag.Role could be null, undefined, or simply never set — and you'd only discover which at runtime
· No serialization contract: When your Razor view also needed to pass state to JavaScript, you were manually building JSON from dynamic properties
We were building distributed state machines across two runtimes (server and browser) — with zero type system support, zero serialization guarantees, and zero compile-time verification.
The First Evolution: Numeric Enums (The Baby Step)
TypeScript's first offering seemed like salvation:
enum UserRole {
Guest, // 0
User, // 1
Admin // 2
}
And in the component:
if (user.role === UserRole.Admin) {
// render admin controls
}
This was better. We had autocomplete. Refactoring worked. But a silent danger remained:
// This compiles fine. No error.
if (user.role === 2) {
// Is this Admin? Guest + 2? Who knows?
}
// API returns { role: 2 }. Is that valid? What about 999?
const role: UserRole = 999; // TypeScript allows this!
Numeric enums are fundamentally porous. Any number can inhabit them. They're not type-safe in the meaningful sense — they're just named constants with a hole in the bottom.
The True Upgrade: String Enumerations as Nominal Types
This is where the real safety begins:
enum UserRole {
Guest = "GUEST",
User = "USER",
Admin = "ADMIN"
}
enum Permission {
Read = "READ",
Write = "WRITE",
Delete = "DELETE"
}
Now something profound happens:
// ❌ TypeScript ERROR: Type '"ADMIN"' is not assignable to type 'UserRole'
const role: UserRole = "ADMIN";
// ❌ ERROR: Type '"READ"' cannot be assigned to 'UserRole'
const perm: UserRole = Permission.Read;
// ✅ Only this works
const role: UserRole = UserRole.Admin;
This is nominal typing in a structural type system. Even though UserRole.Admin resolves to the string "ADMIN", TypeScript treats the enum member as a distinct, opaque type. You cannot accidentally pass a raw string. You cannot mix enums that share the same string value.
This is not just type safety — it's domain safety.
Why String Enums Beat Numeric Enums
| Concern | Numeric Enum | String Enum |
|---|---|---|
| Debugging | 0, 1, 2 in logs | "ADMIN" in logs |
| Serialization | Numbers — ambiguous | Strings — self-describing |
| API contracts | Fragile to reordering | Stable across versions |
| Database storage | Requires lookup table | Human-readable directly |
| Cross-system communication | Number mapping required | String — universal |
| Type safety | Porous (any number works) | Opaque (only enum member) |
| Refactoring safety | Renaming safe, reordering unsafe | Fully safe |
String enums aren't just "better enums." They're a different category of safety.
Beyond Enums: The Full Type System Arsenal
The journey doesn't end with string enums. Modern TypeScript offers increasingly powerful tools for modeling UI state — each building on the last.
Level 1: String Enums (Compile-time constants)
enum Theme { Light = "LIGHT", Dark = "DARK" }
Safety: You can't use the wrong string.
Weakness: You can forget to handle a variant.
Level 2: Union Types (Exhaustiveness via compiler flags)
type Theme = "light" | "dark" | "system";
Safety: Lighter syntax than enums.
Weakness: Still just strings at runtime. No associated data.
Level 3: Discriminated Unions (Full state machines with associated data)
type UserState = {
role: UserRole;
permissions: Permission[];
name: string;
};
type UIState =
| { status: "loading"; startedAt: number }
| { status: "loaded"; data: UserState; permissions: Permission[] }
| { status: "error"; message: string; code: number; retry: () => void }
| { status: "unauthorized"; requiredRole: UserRole };
Safety: The compiler proves you've handled every state.
Power: Each state carries its own specific data shape.
Level 4: Template Literal Types (Compile-time string computation)
type EventName = `user:${UserRole}:${Permission}`;
// "user:GUEST:READ" | "user:GUEST:WRITE" | "user:USER:READ" | ...
Safety: String patterns verified at compile time.
Use case: WebSocket events, Redux action types, analytics events.
Level 5: Const Assertions and as const (Immutable value-level definitions)
const ROLES = ["GUEST", "USER", "ADMIN"] as const;
type UserRole = typeof ROLES[number]; // "GUEST" | "USER" | "ADMIN"
// Single source of truth: runtime array AND compile-time type
Safety: No duplication between runtime values and types.
Power: Iterate at runtime, type-check at compile time.
The Pattern That Changes Everything: Exhaustiveness Checking
Here's where discriminated unions transcend simple enums:
type UIState =
| { status: "loading" }
| { status: "loaded"; data: UserState }
| { status: "error"; message: string }
| { status: "unauthorized" };
function render(state: UIState): JSX.Element {
switch(state.status) {
case "loading":
return <Spinner />;
case "loaded":
return <Dashboard data={state.data} />;
case "error":
return <Error message={state.message} />;
case "unauthorized":
return <Login />;
default: {
// This line is the magic.
// If we add a new status and forget to handle it,
// TypeScript ERROR: 'state' is not 'never'
const _exhaustive: never = state;
return _exhaustive;
}
}
}
This is what ViewBag could never do. The compiler proves you've considered every possible state. Add a new variant to UIState, and your code won't compile until you handle it everywhere.
This isn't just type checking. This is behavioral proof.
The Architectural Comparison: Five Dimensions of Safety
Let's compare across the full spectrum:
| Dimension | ViewBag | Numeric Enum | String Enum | Discriminated Union |
|---|---|---|---|---|
| Type checking | None (runtime) | Porous (any number) | Opaque (member only) | Provable (exhaustive) |
| Serialization | Manual, fragile | Numbers, ambiguous | Self-describing strings | Self-describing + data |
| Invalid states | All invalid states representable | Extra numbers allowed | Only valid members | Impossible to represent |
| Missing handlers | Silent runtime bug | Silent runtime bug | Silent runtime bug | Compile error |
| Associated data | Any shape, no contract | None | None | Per-state data shapes |
| Cross-boundary safety | Zero | Low | Medium | High |
| Refactoring safety | None (string find) | Partial (number drift) | Full (exhaustive check) | Full (exhaustive check) |
| Documentation | Scattered, implicit | Enum name only | Enum name only | Type IS documentation |
| Runtime overhead | Dynamic dispatch | Enum object | Enum object | None (compiled away) |
The Deeper Insight: We Were Always Doing This
Here's what struck me when I traced this evolution:
The concept hasn't changed. The vocabulary and tooling have.
In the Razor days, we were accidentally building distributed state machines with string keys and dynamic objects. We had:
· No name for the pattern
· No compiler checking our logic
· No way to prove completeness
· No serialization guarantees
Today, TypeScript gives us the vocabulary:
· Discriminated unions = State machines
· Exhaustiveness checking = Proof of completeness
· String enums = Opaque domain values
· Template literal types = Computed domain strings
· as const = Single source of truth
We're doing the exact same job: communicating state, permissions, and behavior from backend to UI. But now:
· The compiler is on our team
· Invalid states are inexpressible
· Missing handlers are compile errors
· The type system documents our intent
What We Gained, What We Lost
It's worth being honest about the tradeoffs.
What we gained:
· Safety at scale — refactor without fear
· Compiler-verified completeness
· Self-documenting code
· Cross-team contracts (backend can export TypeScript types)
· CI/CD catching errors before deployment
What we lost:
· Prototyping speed — ViewBag took zero setup
· The ability to pass anything without defining it first
· A certain productive chaos during exploration phases
But here's the thing: the friction TypeScript introduces is productive friction. It asks you to define your types before you've fully understood them — and in doing so, it forces you to understand your domain faster.
The craft is knowing when to stay dynamic (prototyping, exploration) and when to formalize (production, team code, maintained systems).
Practical Architecture: How I Model UI State Now
Here's my current approach for a feature with loading, data, and error states:
type UserState = {
role: UserRole;
permissions: Permission[];
name: string;
};
// 1. Domain enums — string-based, opaque
enum UserRole {
Guest = "GUEST",
User = "USER",
Admin = "ADMIN"
}
enum Permission {
Read = "READ",
Write = "WRITE",
Delete = "DELETE"
}
// 2. Role-to-permission mapping — compile-time checked
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.Guest]: [Permission.Read],
[UserRole.User]: [Permission.Read, Permission.Write],
[UserRole.Admin]: [Permission.Read, Permission.Write, Permission.Delete],
};
// TypeScript ERROR if we forget a role — Record enforces completeness
// 3. UI state as discriminated union
type DashboardState =
| { status: "loading" }
| { status: "loaded"; user: UserState }
| { status: "error"; message: string; retry: () => void }
| { status: "unauthorized"; requiredRole: UserRole };
// 4. Permission check — type-safe, not string-checked
function canPerform(user: UserState, permission: Permission): boolean {
return user.permissions.includes(permission);
}
// 5. Component with exhaustive rendering
function Dashboard({ state }: { state: DashboardState }) {
switch(state.status) {
case "loading":
return <Skeleton />;
case "loaded":
if (canPerform(state.user, Permission.Write)) {
return <EditableDashboard user={state.user} />;
}
return <ReadOnlyDashboard user={state.user} />;
case "error":
return <ErrorBanner message={state.message} onRetry={state.retry} />;
case "unauthorized":
return <RoleUpgradePrompt required={state.requiredRole} />;
}
}
Every invalid state is impossible. Every missing handler is a compile error. Every permission check is type-safe. Every refactoring is automated.
Conclusion: The Story Continues
When I trace the line from ViewBag.IsAdmin to UserRole.Admin to discriminated unions with exhaustiveness checking, I don't see a change in what we're doing.
I see the same pattern evolving across eras:
· State management
· Permission handling
· Conditional rendering
· Feature flagging
· Error boundary modeling
The goal was always clear communication between backend and frontend. What changed is how much help we get from our tools and what we can prove about our code.
ViewBag was a post-it note on a dynamic object.
Numeric enums were named constants with a hole.
String enums are opaque domain values — the first real safety.
Discriminated unions are provable state machines.
We're still writing the same story. We just have a much better editor — and now, a compiler that can prove our story has no plot holes.
Thanks for reading. If you've made a similar journey from dynamic server-rendered UIs to type-safe frontend architectures, I'd love to hear about the moment you realized the compiler was now on your team.
Top comments (0)