Modelling Real-World Flexibility with TypeScript Discriminated Unions
TypeScript

Written By: Samuel Mkamanga

Last Update: Wed Feb 25 2026


Modelling Real-World Flexibility with TypeScript Discriminated Unions

Very often in our Typescript programs we want variables that can hold more than one type of value, we can do that using union types in Typescript.

In this Discussion will walk through the use of discriminated union types in a real-world scenario using menu-bar navigation as an example.

Figure 1: At the end we will have a menu-bar that looks like this

What are we Building?

We are creating a menu-bar that has multiple Items at the top level and can contain a collection of other menu items.

A menu item could be a button with click event, a dropdown menu with child items or a link that can point to external references.
Every menu item must have either Icon or label or both.
The menu items can also have nested children with similar properties.

Having said this let’s build the discriminated unions step by step.

Step 1: Define a discriminant property

Discriminant property holds a unique value and must be in all the Types we are uniting.

In these types the discriminant will be a property called “type” but it can be named anything:

typescript
1type ButtonItem = {
2  type: "button";   //  👈   discriminant 
3  onClick: () => void;
4};
5
6
7
8type LinkItem = {
9  type: "link";   //  👈   discriminant 
10  href: string;
11  isExternal?: boolean;
12};
13
14
15
16type GroupItem = {
17  type: "group";   //  👈  discriminant 
18  navItems: NavigationMenuItem[];
19};

Note that the discriminant is a literal type and it has to be that way for discriminated unions to work.

Step 2: Join the Types

Now we can assign them to a unified type using this “|“ character.

typescript
1type NavigationMenuItem =
2    | ButtonItem
3    | LinkItem
4    | GroupItem;

This mean that we can create a variable that can hold a button, a link or a group combination of buttons and links:

Notice how typescript auto-completes to the discriminants that we have defined.

That means when we choose button the only valid property we will be passing in is onClick function.

The same with property “group” will only allow property “navItems” which will hold a list of child items.

typescript
1let menuItem: NavigationMenuItem
2menuItem = {
3    type: 'button',
4    onClick: () => null
5}
6menuItem = {
7    type: 'group',
8    navItems: [
9        {
10            type: 'button',
11            onClick() { }
12        },
13        {
14            type: 'link',
15            href: "https://example.com",
16            isExternal: true
17        },
18        {
19            type: 'group',
20            navItems: []
21        }]
22}

Step 3: Add non-discriminated properties (Optional)

These are properties that are not being discriminated or we are going to need them for every instance of the variable, in this case these are “label” or “icon”

Remember that we need a label or icon or both for our menu item the type will look something like this:

typescript
1type Label = 
2    | { label: string; icon?: any } 
3    | { label?: string; icon: any }; 
4
5

Then we will add it to our menu item type

typescript
1typeNavigationMenuItem=Label& (ButtonItem
2    |LinkItem
3    |GroupItem);

Now our updated example will look like this with label and icon properties:

typescript
1type Label = | { label: string; icon?: any }
2    | { label?: string; icon: any };
3    
4
5
6type NavigationMenuItem =
7    Label & (ButtonItem
8        | LinkItem
9        | GroupItem);
10        
11
12let menuItem: NavigationMenuItem
13
14// Example with both Icon and Label 
15menuItem = {
16    label: 'Home',
17    icon: HomeIcon,
18    type: 'button',
19    onClick: () => null
20}
21
22menuItem = {
23    type: 'group',
24    label: 'Dropdown',
25    navItems: [{
26        label: "Open",  // Example with label only  
27        type: 'button',
28        onClick() { }
29    },
30    {
31        type: 'link',   // Example with link only  
32        icon: ExternalLinkIcon,
33        href: "https://example.com",
34        isExternal: true
35    },
36    {
37        type: 'group',
38        label: 'Nested Dropdown Menu',
39        navItems: []
40    }
41    ]
42
43}
44
45

Now let’s build the Menu Bar.

Creating the Menu bar

We are going use react project created with Vite and Shadcn UI library for illustration but this logic works in any TypeScript and JavaScript Library. I’ve already created one you can clone from GitHub Most-Excellent-Theophilus/navigation-with-discriminated-unions.

Creating Menu bar Component

This uses menu-bar from shadcn-ui to build our menu from the variable we are going to create:

typescript
1import {
2    Menubar,
3    MenubarMenu,
4    MenubarTrigger,
5    MenubarContent,
6    MenubarItem,
7    MenubarSub,
8    MenubarSubTrigger,
9    MenubarSubContent,
10    MenubarLabel,
11
12} from "@/components/ui/menubar";
13
14
15import type { NavigationMenuItem as NavItem } from "@/navigation-feature/navigation.types";
16
17type Props = {
18    items: NavItem[];
19};
20
21export function NavigationMenuBar({ items }: Props) {
22    return (
23        <  Menubar  >
24            {items.map((item, index) => (
25                <  TopLevelItem key={index} item={item} />
26            ))}
27        </  Menubar  >
28    );
29
30}
31
32function TopLevelItem({ item }: { item: NavItem }) {
33    const Icon = item.icon;
34    const LabelContent = (
35        <  span className="flex items-center gap-2" title={item.title}  >
36            {Icon && <  Icon className="h-4 w-4" />}
37            {item.label}
38        </  span  >
39    );
40
41    if (item.type === "group") {
42        return (
43            <  MenubarMenu  >
44                <  MenubarTrigger  >  {LabelContent}  </  MenubarTrigger  >
45                <  MenubarContent  >
46                    {item.navItems.map((child, i) => (
47                        <  RenderSubItem key={i} item={child} />
48                    ))}
49                </MenubarContent  >
50            </MenubarMenu  >
51        );
52    }
53
54
55
56
57    return (
58        <  MenubarMenu  >
59            <  MenubarLabel  >
60                {item.type === "link" ? (
61                    <  a href={item.href}  >  {LabelContent}  </  a  >
62                ) : (
63                    <  button onClick={item.onClick}  >  {LabelContent}  </  button  >
64                )}
65            </  MenubarLabel  >
66        </  MenubarMenu  >
67    );
68
69
70}
71
72
73
74
75
76
77function RenderSubItem({ item }: { item: NavItem }) {
78    const Icon = item.icon;
79
80
81
82
83    const LabelContent = (
84        <  span className="flex items-center gap-2"  >
85            {Icon && <  Icon className="h-4 w-4" />}
86            {item.label}
87        </  span  >
88    );
89
90
91
92
93    if (item.type === "group") {
94        return (
95            <  MenubarSub  >
96                <  MenubarSubTrigger  >  {LabelContent}  </  MenubarSubTrigger  >
97                <  MenubarSubContent  >
98                    {item.navItems.map((child, i) => (
99                        <  RenderSubItem key={i} item={child} />
100                    ))}
101                </  MenubarSubContent  >
102            </  MenubarSub  >
103        );
104    }
105
106
107
108
109    if (item.type === "link") {
110        return (
111            <  MenubarItem asChild  >
112                <  a
113                    href={item.href}
114                    target={item.isExternal ? "_blank" : undefined}
115                    rel={item.isExternal ? "noopener noreferrer" : undefined}
116                >
117                    {LabelContent}
118                </  a  >
119            </  MenubarItem  >
120        );
121    }
122
123
124
125
126    return (
127        <  MenubarItem onClick={item.onClick}  >
128            {LabelContent}
129        </  MenubarItem  >
130    );
131
132
133}
134
135

Final Result’s

Using the component we created above we can now pass is this variable:

typescript
1
2
3
4const menuBar: NavigationMenuItem[] = [ // Link Item Label and Icon
5    {
6        type: "link",
7        title: 'Link Item Label and Icon',
8        icon: Home,
9        label: "Home",
10        href: "#home",
11    },
12
13    // Link Item Icon Only
14    {
15        type: "link",
16        icon: Search,
17        title: 'Link Item Icon Only',
18        href: "##search",
19    },
20
21    // Link Item Label Only
22    {
23        type: "link",
24        title: 'Link Item Label Only',
25        label: "Dashboard",
26        href: "##dashboard",
27    },]
28
29

And the UI will render this:

If well add in the group type will have something like this:

Then to our final example you can get the from my GitHub Repository here, when you add it to the component it should look like the initial Image we had at the beginning of this discussion:

Conclusion

Discriminated unions can be used in various way’s this is just one out of many, by providing a robust way to manage multiple types under a united type they enforce that all possible cases are handled there by reducing bugs and creating memory safe Typescript Programs.

If you are interested in learning more about Programming and Building project’s feel free to contact me below I give one on one lecture’s online using Google meet or Microsoft teams

Written By: Samuel Mkamanga

Last Update: Wed Feb 25 2026