14

Imagine a language which doesn't allow multiple value constructors for a data type. Instead of writing

data Color = White | Black | Blue

we would have

data White = White
data Black = Black
data Blue = Black
type Color = White :|: Black :|: Blue

where :|: (here it's not | to avoid confusion with sum types) is a built-in type union operator. Pattern matching would work in the same way

show :: Color -> String
show White = "white"
show Black = "black"
show Blue = "blue"

As you can see, in contrast to coproducts it results in a flat structure so you don't have to deal with injections. And, unlike sum types, it allows to randomly combine types resulting in greater flexibility and granularity:

type ColorsStartingWithB = Black :|: Blue

I believe it wouldn't be a problem to construct recursive data types as well

data Nil = Nil
data Cons a = Cons a (List a)
type List a = Cons a :|: Nil

I know union types are present in TypeScript and probably other languages, but why did the Haskell committee chose ADTs over them?

shock_one
  • 5,845
  • 3
  • 28
  • 39
  • 6
    Union types are a form of subtyping, and subtyping does not play nicely with typeclasses or type inference. – Alexis King Nov 15 '16 at 22:37
  • 7
    I'm not a good enough explainer of mathematics to do this justice in a full answer, but in category theory the opposite of a Cartesian product is a disjoint union, not a set union. – Benjamin Hodgson Nov 15 '16 at 23:56
  • 3
    While this question is indeed closely related to [*Why do Maybe/Optional types use a Just/Some type instead of the actual type?*](http://stackoverflow.com/q/39582869/2751851), I don't think it is a duplicate. There, the intention was merely to eliminate the distinction between `Just x` and `x`, while here something rather more subtle is being suggested, even if the consequences are similar. – duplode Nov 16 '16 at 01:30
  • @BenjaminHodgson That leaves me wondering... a sum is to an union as a product is to an intersection. The lack of intersection types, though, seems to be a topic raised less often than that of untagged unions. – duplode Nov 16 '16 at 02:30
  • @duplode For what it's worth, TypeScript has both union types and intersection types. In my experience working with TypeScript, intersection types seem to be less useful in daily practice. I don't know why that is. – Benjamin Hodgson Nov 16 '16 at 16:53
  • @BenjaminHodgson Perhaps it's just that we want something to be one among many disparate possibilities more often than we want something to be several disparate things at once. (This discussion reminds me that, a while ago, I saw someone elsewhere saying that they had a record with fields of different types and wanted to use said fields implicitly, without using the accessors. I didn't make the connection at the time, but that person was looking for something very much like an intersection type.) – duplode Nov 16 '16 at 17:19

4 Answers4

17

Haskell's sum type is very similar to your :|:.

The difference between the two is that the Haskell sum type | is a tagged union, while your "sum type" :|: is untagged.

Tagged means every instance is unique - you can distunguish Int | Int from Int (actually, this holds for any a):

data EitherIntInt = Left Int | Right Int

In this case: Either Int Int carries more information than Int because there can be a Left and Right Int.

In your :|:, you cannot distinguish those two:

type EitherIntInt = Int :|: Int

How do you know if it was a left or right Int?

See the comments for an extended discussion of the section below.

Tagged unions have another advantage: The compiler can verify whether you as the programmer handled all cases, which is implementation-dependent for general untagged unions. Did you handle all cases in Int :|: Int? Either this is isomorphic to Int by definition or the compiler has to decide which Int (left or right) to choose, which is impossible if they are indistinguishable.

Consider another example:

type (Integral a, Num b) => IntegralOrNum a b = a :|: b    -- untagged
data (Integral a, Num b) => IntegralOrNum a b = Either a b -- tagged

What is 5 :: IntegralOrNum Int Double in the untagged union? It is both an instance of Integral and Num, so we can't decide for sure and have to rely on implementation details. On the other hand, the tagged union knows exactly what 5 should be because it is branded with either Left or Right.


As for naming: The disjoint union in Haskell is a union type. ADTs are only a means of implementing these.

ThreeFx
  • 7,250
  • 1
  • 27
  • 51
  • Could you please elaborate on why it's impossible to verify that all the cases have been handled in untagged unions? – shock_one Nov 15 '16 at 23:05
  • One could either let `Int :|: Int` be definitionally equal to `Int` or simply prohibit untagged unions between the same type. You could also recover tagged unions with `data L a = L a; data R a = R a; type Either a b = L a :|: R a` so barring the ambiguity of unions between equal types (which is simply resolved by giving `:|:` the desired semantics by definition) this answer doesn't address the question at all. – user2407038 Nov 15 '16 at 23:13
  • @user2407038 See the `IntegralOrNum` example. You introduce implementation-dependent ambiguity, which is a rather nasty source of bugs. – ThreeFx Nov 15 '16 at 23:14
  • As for `IntegralOrNum` .. the declaration `type (Integral a, Num b) => IntegralOrNum a b = Either a b` is complete nonsense in real Haskell, so I don't see why it would be valid with the imaginary untagged union. Even if it was valid, then `5 :: IntegralOrNum Int Double` would be equal to whatever the `Num (a :|: b)` instance (if it even exists, again, why is this an assumption?) says it will be. – user2407038 Nov 15 '16 at 23:18
  • @user2407038 What if I start making unions of unions of unions? If the types I'm unioning are exported from other modules I might not realise I'm doing this (and it might change *after* I first do it when I update a dependency). I would expect that if I have `a :|: b` and two functions `a -> r, b -> r` I could combine them into `(a :|: b) -> r`, but there are squiggly corner cases in the behaviour of that if the compiler is silently merging overlapping cases. If the union types can be abstract, it might be impossible for me *or* the compiler to know whether or not that's happening. – Ben Nov 15 '16 at 23:22
  • @user2407038 I'm sure you *can* give a well-defined semantics to a system with types like this. But there *are* questions that would need work. – Ben Nov 15 '16 at 23:23
  • @user2407038 In Haskell this would be a data type (which is possible using `DatatypeContexts`) but it is just an example to show the untagged union doesn't know whether to treat `5` as `Num` or `Integral` whereas the tagged union knows so unambigouosly. – ThreeFx Nov 15 '16 at 23:23
  • @Ben Yes, there are issues that would need work (they are not mentioned in this answer). I would expect that the polymorphic function `caseSum :: forall a b r . (a -> r) -> (b -> r) -> a :|: b -> r` would be definable (or primitive) in such a language - what edge cases are there, if you simply disallow invalid unions? @ThreeFx You are assuming that the imaginary `:|:` is an injective, generative type constructor, but that need not be the case - if it is an indexed family of types, then you cannot define type class instances for it. – user2407038 Nov 15 '16 at 23:38
  • @user2407038 Isn't disallowing invalid unions the same thing as using tagged unions? I fail to see the difference. They are always disjoint, hence implicitly tagged. – ThreeFx Nov 15 '16 at 23:40
  • 1
    @user2407038 If `a :|: b` is just not defined for all pairs of types, then you get annoyances when trying to use it on abstract types; a dependency could change its representation in a way that preserves its API in all details except that different possible unions are allowed. If we only get to have union **or** corproduct, then coproduct is more useful in my opinion **precisely** because it's injective and generative; the OP is discussing unions as an *alternative* to ADTs, so "you can't define type class instances for it" and "some unions just don't work" isn't very satisfactory. – Ben Nov 15 '16 at 23:52
  • @Ben You don't lose the injective, generative coproduct even if there is no primitive, since type constructors are still nominally distinct: `data U a b = U (Left a :|: Right b)`. Of course with such a type, nested coproducts are denoted by the number of `U` constructors. Since you can recover the coproduct from the union, you are not picking between union *or* coproduct, you are picking between union and coproduct *or* just coproduct. – user2407038 Nov 16 '16 at 00:18
  • @user2407038 IMO you run into the problem that some untagged unions are also coproducts whether others aren't. How do you want to handle these? Furthermore, I still think that disallowing invalid unions would yield a type constructor isomorphic to the tagged union. Last but not least, how would you use these in programming? You can't pattern match on any constructors, etc. You'd be at the compiler's mercy to figure out the types of the variables when attempting to define functions like that. Consider `a :|: b -> a -> b -> (a, b)`, which complements its argument with the complement. – ThreeFx Nov 16 '16 at 00:53
  • @ThreeFx why tagged unions are considered as sum types while untagged unions are not? – Sourav Kannantha B Feb 14 '21 at 10:17
15

I will try to expand the categorical argument mentioned by @BenjaminHodgson.

Haskell can be seen as the category Hask, in which objects are types and morphisms are functions between types (disregarding bottom).

We can define a product in Hask as tuple - categorically speaking it meets the definition of the product:

A product of a and b is the type c equipped with projections p and q such that p :: c -> a and q :: c -> b and for any other candidate c' equipped with p' and q' there exists a morphism m :: c' -> c such that we can write p' as p . m and q' as q . m.

product

Read up on this in Bartosz' Category Theory for Programmers for further information.

Now for every category, there exists the opposite category, which has the same morphism but reverses all the arrows. The coproduct is thus:

The coproduct c of a and b is the type c equipped with injections i :: a -> c and j :: b -> c such that for all other candidates c' with i' and j' there exists a morphism m :: c -> c' such that i' = m . i and j' = m . j.

coproduct

Let's see how the tagged and untagged union perform given this definition:

The untagged union of a and b is the type a :|: b such that:

  • i :: a -> a :|: b is defined as i a = a and
  • j :: b -> a :|: b is defined as j b = b

However, we know that a :|: a is isomorphic to a. Based on that observation we can define a second candidate for the product a :|: a :|: b which is equipped with the exact same morphisms. Therefore, there is no single best candidate, since the morphism m between a :|: a :|: b and a :|: b is id. id is a bijection, which implies that m is invertible and "convert" types either way. A visual representation of that argument. Replace p with i and q with j.

coproduct untagged

Restricting ourselves Either, as you can verify yourself with:

  • i = Left and
  • j = Right

This shows that the categorical complement of the product type is the disjoint union, not the set-based union.

The set union is part of the disjoint union, because we can define it as follows:

data Left a = Left a
data Right b = Right b
type DisjUnion a b = Left a :|: Right b

Because we have shown above that the set union is not a valid candidate for the coproduct of two types, we would lose many "free" properties (which follow from parametricity as leftroundabout mentioned) by not choosing the disjoint union in the category Hask (because there would be no coproduct).

ThreeFx
  • 7,250
  • 1
  • 27
  • 51
  • The English definitions of product and coproduct are very dense. I think this answer would be easier to understand with some pictures – Benjamin Hodgson Nov 16 '16 at 10:55
  • @BenjaminHodgson Like this? – ThreeFx Nov 16 '16 at 13:50
  • From where "we know that `a :|: a` is isomorphic to `a`."? what is the supposed definition of `a :|: a`? – Eduardo Pareja Tobes Nov 19 '16 at 16:40
  • @EduardoParejaTobes The untagged union loses the information if the `a` in the union came from the left or the right set. You can imagine it as being one of two socks: You know it's a sock, but it could be a left or right one, so for all you know it could be a single sock. The tagged union works like those socks with L and R printed: If you have one of these socks, you know exactly which one it is, which translates to you knowing where your `a` came from. – ThreeFx Jan 28 '17 at 23:03
  • @ThreeFx thanks, but what I'm asking for is a conceptual (that is, category-theoretical) definition of untagged unions. – Eduardo Pareja Tobes Jan 28 '17 at 23:42
5

This is an idea I've thought a lot about myself: a language with “first-class type algebra”. Pretty sure we could do about everything this way that we do in Haskell. Certainly if these disjunctions were, like Haskell alternatives, tagged unions; then you could directly rewrite any ADT to use them. In fact GHC can do this for you: if you derive a Generic instance, a variant type will be represented by a :+: construct, which is in essence just Either.

I'm not so sure if untagged unions would also do. As long as you require the types participating in a sum to be discernibly different, the explicit tagging should in principle not be necessary. The language would then need a convenient way to match on types at runtime. Sounds a lot like what dynamic languages do – obviously comes with quite some overhead though.
The biggest problem would be that if the types on both sides of :|: must be unequal then you lose parametricity, which is one of Haskell's nicest traits.

leftaroundabout
  • 117,950
  • 5
  • 174
  • 319
  • Why do the left and right hand sides being unequal make you lose parametricity? If you make such an assertion, then a type like e.g. `forall a b . a :|: b -> ...` is a proof that `a` and `b` are disequal, but one cannot necessarily access this proof. – user2407038 Nov 15 '16 at 23:28
  • It seems this is the important point - "As long as you require the types participation in a sum to be discernibly different, the explicit tagging should in principle not be necessary." - one must require that the types are not only disequal, but the stronger condition of them being distinguishable by their constructors. Perhaps this is too strong a condition to be useful? – user2407038 Nov 15 '16 at 23:28
1

Given that you mention TypeScript, it is instructive to have a look at what its docs have to say about its union types. The example there starts from a function...

function padLeft(value: string, padding: any) { //etc.

... that has a flaw:

The problem with padLeft is that its padding parameter is typed as any. That means that we can call it with an argument that’s neither a number nor a string

One plausible solution is then suggested, and rejected:

In traditional object-oriented code, we might abstract over the two types by creating a hierarchy of types. While this is much more explicit, it’s also a little bit overkill.

Rather, the handbook suggests...

Instead of any, we can use a union type for the padding parameter:

function padLeft(value: string, padding: string | number) { // etc.

Crucially, the concept of union type is then described in this way:

A union type describes a value that can be one of several types.

A string | number value in TypeScript can be either of string type or of number type, as string and number are subtypes of string | number (cf. Alexis King's comment to the question). An Either String Int value in Haskell, however, is neither of String type nor of Int type -- its only, monomorphic, type is Either String Int. Further implications of that difference show up in the remainder of the discussion:

If we have a value that has a union type, we can only access members that are common to all types in the union.

In a roughly analogous Haskell scenario, if we have, say, an Either Double Int, we cannot apply (2*) directly on it, even though both Double and Int have instances of Num. Rather, something like bimap is necessary.

What happens when we need to know specifically whether we have a Fish? [...] we’ll need to use a type assertion:

let pet = getSmallPet();

if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
}
else {
    (<Bird>pet).fly();
}

This sort of downcasting/runtime type checking is at odds with how the Haskell type system ordinarily works, even though it can be implemented using the very same type system (also cf. leftaroundabout's answer). In contrast, there is nothing to figure out at runtime about the type of an Either Fish Bird: the case analysis happens at value level, and there is no need to deal with anything failing and producing Nothing (or worse, null) due to runtime type mismatches.

Community
  • 1
  • 1
duplode
  • 33,731
  • 7
  • 79
  • 150