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.
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:
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.
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.
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:
1type Label =
2 | { label: string; icon?: any }
3 | { label?: string; icon: any };
4
5Then we will add it to our menu item type
1typeNavigationMenuItem=Label& (ButtonItem
2 |LinkItem
3 |GroupItem);Now our updated example will look like this with label and icon properties:
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
45Now 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:
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
135Final Result’s
Using the component we created above we can now pass is this variable:
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
29And 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