DEV Community

Cover image for We Were Always Building State Machines — From .NET ViewBag to TypeScript Discriminated Unions
mehrdad nka
mehrdad nka

Posted on

We Were Always Building State Machines — From .NET ViewBag to TypeScript Discriminated Unions

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" };
Enter fullscreen mode Exit fullscreen mode

And in the Razor view:

@if(ViewBag.CanEdit) {
    <button>Edit</button>
}

@if(ViewBag.Role == "Admin") {
    <admin-panel />
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

And in the component:

if (user.role === UserRole.Admin) {
    // render admin controls
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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" | ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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} />;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)