The following is something I know in my heart but always take a minute to reconstruct when it comes up, so I am posting it here for posterity.

Typescript lets you write type C = A | B. This often feels more ergonomic than when many languages force you to tag the union. C = Either A B, then doing left a or right b, all followed by needing to unwrap the values at the usage site... it feels like busy work! Typescript's untagged unions often lead to cleaner user-side APIs that don't require boilerplate boxing up of values.

Let’s say that you want to represent an optional value, Option<T>. You might have just(x) (for a value that is present) or none() (for a value that is absent).

type Option<T> = ...;

function just<T>(x: T): Option<T> { 
  ...
}

function none<T>(): Option<T> {
  ...
}

This leads to just(1) of type Option<number> , none() could be of type Option<number> but also of Option<string>.

You might also want to hold an Option<Option<string>>.

For example, we might hold an address for a user in an Option<string>. they either provide it to us or they don't. On top of that we might have a form that lets you submit changes, and the address field would be of Option<Option<string>>:

  • none() -> no change is being requested
  • option(just(address)) -> please change the address to address
  • option(none()) -> please clear any saved address

In Javascript we have undefined that can serve as a nice representation of the absence of a value, so let's implement Option<T> that way, using undefined as the "empty" value:

type Option<T> = T | undefined

function just<T>(x: T): Option<T> {
  return x
}

function none<T>(): Option<T> {
  return undefined
}

This might seem to work well at first, but in this model we can't compose Option types!

none() -> undefined
just(none()) -> undefined

There are no type errors anywhere, and this isn't any sort of soundness issue with Typescript. But simply put, none() and just(none()) give the same result. Option<Option<T>> is the same type as Option<T>.

End result: “We have the value, and that value is none” is indistinguishable from “we don’t have a value”! Option<undefined> is an even shorter path to this same argument, where just(undefined) === none().

An alternative technique to handle this is a discriminating tag for each kind of Option<T> value:

type Option<T> = {kind: "just", value: T} |
 {kind: "none"}

function just<T>(x: T): Option<T> {
  return {kind: "just", value: x}
}

function none<T>(): Option<T> {
  return {kind: "none"}
}

Here we are golden, and we have different values to represent just(none()) and none()

just(none()) -> {kind: "just", value: {kind: "none"}}
none() -> {kind: "none"}

At a bit of a higher level, we can convince ourselves that this tagged version of Option validates the following kind of property:

if x == y (structurally, not identitically)
then:
  x == none() and y == none()
or
  x == just(x.value)  and y == just(x.value)

This property captures an idea of being a "proper" data type. Equal values are only equal if they are from the same constructor. And thanks to that, equal values implies equal components.

(If you want to prove this: imagine if x = just(z) for some z, or if x = none() and work from that case disjunction, with some details about how any value in Option<T> is one of the two)

In fact, here, you don’t even really need the tagging on the none() case. You could simply use undefined as a value for none(), and otherwise wrap:

type Option<T> = {value: T} | undefined

This is simpler than the tagged model, but the tagging is happening simply by the object wrapping, with undefined being a value reserved for one variant.

The difficulty with a non-tagged union type is that T | undefined | undefined is the same as T | undefined. If you want the composition to actually represent something like what you get with data types in other languages, you need to make sure there is no overlap between the types.

If undefined is not in T, then T | undefined does indeed give you a bigger type. But from there You’re not getting a bigger type, and so you won’t be able to track how many levels deep you are at.

Untagged union types that Typescript give you are nice, but if you have overlaps when using them, your result type might be holding less information than you think.