{"article":{"slug":"typescript-and-haskell-unions-and-singletons","title":"TypeScript and Haskell: Unions & Singletons","subtitle":null,"summary":"Gabriel Garcia explores how TypeScript's combination of union types and literal (singleton) types gives APIs precision, then tries to reproduce the same pattern in Haskell with type families, unboxed sums and singleton naturals, showing where the encodings get awkward and why they fail to behave like a set of types.","content_type":"tutorial","language":"en","canonical_url":"https://garciat.com/posts/haskell-union-types/","author":{"name":"Gabriel Garcia","url":"https://garciat.com/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"garciat.com","url":"https://garciat.com/","listing_slug":null,"listing":null},"topics":[{"name":"Programming Languages","slug":"programming-languages","url":"https://listedarticles.com/topics/programming-languages"},{"name":"Haskell","slug":"haskell","url":"https://listedarticles.com/topics/haskell"},{"name":"TypeScript","slug":"typescript","url":"https://listedarticles.com/topics/typescript"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":428,"reading_minutes":2,"published_at":"2024-12-14T00:00:00.000Z","added_at":"2026-10-27T14:23:31.095Z","updated_at":"2026-10-27T14:23:31.095Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/typescript-and-haskell-unions-and-singletons","markdown_url":"https://listedarticles.com/articles/typescript-and-haskell-unions-and-singletons.md","example":false,"citation":"Gabriel Garcia, garciat.com. \"TypeScript and Haskell: Unions & Singletons.\" 14 Dec 2024. https://garciat.com/posts/haskell-union-types/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://garciat.com/posts/haskell-union-types/"},"body_markdown":"# TypeScript and Haskell: Unions & Singletons\n\n*Gabriel Garcia · December 14, 2024*\n\nI like TypeScript's type system. In particular, I like the combination of\n[Union Types](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types)\n(aka anonymous Sum Types) and\n[Literal Types](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types)\n(aka Singleton Types).\n\nThose features allow us to achieve API precision, while also minding\nconciseness:\n\n```\n// Not very precise\ntype Result = {\n  ok: boolean;\n  error?: string;\n  userRole?: string;\n  avatarSize?: number;\n};\n\n// Much more precise\ntype Result =\n  | { ok: false; error: string }\n  | { ok: true; userRole: \"ADMIN\" | \"GUEST\"; avatarSize: 96 | 128 | 512 };\n```\n\nLet's compare that to Haskell without any language extensions:\n\n```\ndata Result =\n  ResultError { resultError :: String }\n  | ResultOk { resultUserRole :: UserRole, resultAvatarSize :: AvatarSize }\n\ndata UserRole = UserRoleAdmin | UserRoleGuest\n\ndata AvatarSize = AvatarSize96 | AvatarSize128 | AvatarSize512\n```\n\nUnfortunately, Haskell falls short compared to TypeScript:\n\n- We must declare the types `UserRole` and `AvatarSize`.\n- Actual value alternatives (e.g 96, 128) are obfuscated by their respective ADT\n  tags (e.g `AvatarSize96` and `AvatarSize128`).\n\nSo I went on the hunt for Haskell extensions that could help bridge those gaps.\n\nHere's one alternative that I found:\n\n```\n{-# Language DataKinds, GADTs #-}\n\nimport GHC.Base\nimport GHC.TypeLits\n\n---\n\ntype OneOfNat :: [Nat] -> Type\n\ndata OneOfNat ns where\n  Here :: SNat n -> OneOfNat (n ': ns)\n  There :: OneOfNat ns -> OneOfNat (n ': ns)\n\n---\n\ntype AvatarSize = OneOfNat [96, 128, 512]\n\nhello :: AvatarSize -> String\nhello size =\n  case size of\n    Here @96 _ -> \"96\"\n    There (Here @128 _) -> \"128\"\n    There (There (Here @512 _)) -> \"512\"\n\n---\n\nmain = print $ hello $ There (Here SNat)\n```\n\nHere's another alternative:\n\n```\n{-# Language DataKinds, UnboxedSums #-}\n\nimport GHC.Base\nimport GHC.TypeLits\n\n---\n\ntype AvatarSize = (# SNat 96 | SNat 128 | SNat 512 #)\n\nhello :: AvatarSize -> String\nhello size =\n  case size of\n    (# SNat @96 | | #) -> \"96\"\n    (# | SNat @128 | #) -> \"128\"\n    (# | | SNat @512 #) -> \"512\"\n\n---\n\nmain = print $ hello $ (# SNat @96 | | #)\n```\n\nI like this solution because the syntax is flat, compared to the previous nested\n`Here`s and `There`s.\n\nHowever,\n[unpacked sum types](https://gitlab.haskell.org/ghc/ghc/-/wikis/unpacked-sum-types)\nbehave very differently from regular types.\n\nFor example, we cannot do this:\n\n```\ntype family UOneOfNat ns where\n  UOneOfNat [a, b] = (# SNat a | SNat b #)\n  UOneOfNat [a, b, c] = (# SNat a | SNat b | SNat c #)\n\ntype AvatarSize = UOneOfNat [96, 128, 512]\n```\n\nBecause each unpacked sum type has a different kind based on its arity:\n\n```\nMain.hs:10:25: error: [GHC-83865]\n    • Couldn't match kind: '[LiftedRep]\n                     with: '[]\n      Expected kind ‘TYPE (SumRep [LiftedRep, LiftedRep])’,\n        but ‘(# SNat a | SNat b | SNat c #)’ has kind ‘TYPE\n                                                         (SumRep [LiftedRep, LiftedRep, LiftedRep])’\n    • In the type ‘(# SNat a | SNat b | SNat c #)’\n      In the type family declaration for ‘UOneOfNat’\n   |\n10 |   UOneOfNat [a, b, c] = (# SNat a | SNat b | SNat c #)\n   |                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n```\n\nMoreover, neither alternative holds the desired semantics of the type union\nbehaving like a set of types, i.e. normalizing duplicates. We could use\n[type-level Sets](https://hackage.haskell.org/package/type-level-sets-0.8.9.0/docs/Data-Type-Set.html)\nfor this, but that only further complicates our representation.\n\n---\n\n*Originally published at [garciat.com](https://garciat.com/posts/haskell-union-types/).*\n","body_html":"<h1 id=\"typescript-and-haskell-unions-singletons\">TypeScript and Haskell: Unions &amp; Singletons</h1>\n<p><em>Gabriel Garcia · December 14, 2024</em></p>\n<p>I like TypeScript&#39;s type system. In particular, I like the combination of\n<a href=\"https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types\" rel=\"nofollow ugc noopener\">Union Types</a>\n(aka anonymous Sum Types) and\n<a href=\"https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types\" rel=\"nofollow ugc noopener\">Literal Types</a>\n(aka Singleton Types).</p>\n<p>Those features allow us to achieve API precision, while also minding\nconciseness:</p>\n<pre><code>// Not very precise\ntype Result = {\n  ok: boolean;\n  error?: string;\n  userRole?: string;\n  avatarSize?: number;\n};\n\n// Much more precise\ntype Result =\n  | { ok: false; error: string }\n  | { ok: true; userRole: &quot;ADMIN&quot; | &quot;GUEST&quot;; avatarSize: 96 | 128 | 512 };</code></pre>\n<p>Let&#39;s compare that to Haskell without any language extensions:</p>\n<pre><code>data Result =\n  ResultError { resultError :: String }\n  | ResultOk { resultUserRole :: UserRole, resultAvatarSize :: AvatarSize }\n\ndata UserRole = UserRoleAdmin | UserRoleGuest\n\ndata AvatarSize = AvatarSize96 | AvatarSize128 | AvatarSize512</code></pre>\n<p>Unfortunately, Haskell falls short compared to TypeScript:</p>\n<ul><li>We must declare the types <code>UserRole</code> and <code>AvatarSize</code>.</li><li><p>Actual value alternatives (e.g 96, 128) are obfuscated by their respective ADT</p><p>tags (e.g <code>AvatarSize96</code> and <code>AvatarSize128</code>).</p></li></ul>\n<p>So I went on the hunt for Haskell extensions that could help bridge those gaps.</p>\n<p>Here&#39;s one alternative that I found:</p>\n<pre><code>{-# Language DataKinds, GADTs #-}\n\nimport GHC.Base\nimport GHC.TypeLits\n\n---\n\ntype OneOfNat :: [Nat] -&gt; Type\n\ndata OneOfNat ns where\n  Here :: SNat n -&gt; OneOfNat (n &#39;: ns)\n  There :: OneOfNat ns -&gt; OneOfNat (n &#39;: ns)\n\n---\n\ntype AvatarSize = OneOfNat [96, 128, 512]\n\nhello :: AvatarSize -&gt; String\nhello size =\n  case size of\n    Here @96 _ -&gt; &quot;96&quot;\n    There (Here @128 _) -&gt; &quot;128&quot;\n    There (There (Here @512 _)) -&gt; &quot;512&quot;\n\n---\n\nmain = print $ hello $ There (Here SNat)</code></pre>\n<p>Here&#39;s another alternative:</p>\n<pre><code>{-# Language DataKinds, UnboxedSums #-}\n\nimport GHC.Base\nimport GHC.TypeLits\n\n---\n\ntype AvatarSize = (# SNat 96 | SNat 128 | SNat 512 #)\n\nhello :: AvatarSize -&gt; String\nhello size =\n  case size of\n    (# SNat @96 | | #) -&gt; &quot;96&quot;\n    (# | SNat @128 | #) -&gt; &quot;128&quot;\n    (# | | SNat @512 #) -&gt; &quot;512&quot;\n\n---\n\nmain = print $ hello $ (# SNat @96 | | #)</code></pre>\n<p>I like this solution because the syntax is flat, compared to the previous nested\n<code>Here</code>s and <code>There</code>s.</p>\n<p>However,\n<a href=\"https://gitlab.haskell.org/ghc/ghc/-/wikis/unpacked-sum-types\" rel=\"nofollow ugc noopener\">unpacked sum types</a>\nbehave very differently from regular types.</p>\n<p>For example, we cannot do this:</p>\n<pre><code>type family UOneOfNat ns where\n  UOneOfNat [a, b] = (# SNat a | SNat b #)\n  UOneOfNat [a, b, c] = (# SNat a | SNat b | SNat c #)\n\ntype AvatarSize = UOneOfNat [96, 128, 512]</code></pre>\n<p>Because each unpacked sum type has a different kind based on its arity:</p>\n<pre><code>Main.hs:10:25: error: [GHC-83865]\n    • Couldn&#39;t match kind: &#39;[LiftedRep]\n                     with: &#39;[]\n      Expected kind ‘TYPE (SumRep [LiftedRep, LiftedRep])’,\n        but ‘(# SNat a | SNat b | SNat c #)’ has kind ‘TYPE\n                                                         (SumRep [LiftedRep, LiftedRep, LiftedRep])’\n    • In the type ‘(# SNat a | SNat b | SNat c #)’\n      In the type family declaration for ‘UOneOfNat’\n   |\n10 |   UOneOfNat [a, b, c] = (# SNat a | SNat b | SNat c #)\n   |                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^</code></pre>\n<p>Moreover, neither alternative holds the desired semantics of the type union\nbehaving like a set of types, i.e. normalizing duplicates. We could use\n<a href=\"https://hackage.haskell.org/package/type-level-sets-0.8.9.0/docs/Data-Type-Set.html\" rel=\"nofollow ugc noopener\">type-level Sets</a>\nfor this, but that only further complicates our representation.</p>\n<hr />\n<p><em>Originally published at <a href=\"https://garciat.com/posts/haskell-union-types/\" rel=\"nofollow ugc noopener\">garciat.com</a>.</em></p>","headings":[{"level":1,"text":"TypeScript and Haskell: Unions & Singletons","id":"typescript-and-haskell-unions-singletons"}]}}