- Published on
Anti-Instances in Haskell
- Authors
- Name
- Heneli Kailahi
- @kat_getheny
Table of Contents
Prerequisites: You should be familiar with using basic typeclasses. If not, check out Serokell's Introduction to Haskell Typeclasses or the Typeclasses and Instances section from Kowainik's Strategic Deriving.
Typeclass instances are a powerful tool for specifying which overloaded operations are allowed on which types. But what about specifying which operations are disallowed on which types? With the arrival of Unsatisfiable
in GHC 9.8.1, Haskell has better answers now than ever before.
Anti-Instances: Past and Present
An anti-instance is the intentional rejection of a possible typeclass instance. Anti-instances can be defined implicitly by omission or explicitly by custom type error.
Omitted Instances
The easiest and most common method for disallowing a typeclass operation on a given type is simply not providing the corresponding typeclass instance.
As an example, let's look at omitted instances on Data.Semigroup.First
:
newtype First a = First { getFirst :: a }
instance Semigroup (First a) where
(<>) :: First a -> First a -> First a
a <> _ = a
First
comes with a Semigroup
instance as (<>)
, an append operation obeying the associativity law1, can be implemented for combining First
values.
λ> First 99 <> First 12 <> First 30 <> First 7
First 99
λ> ((First 12 <> First 30) <> First 7) <> First 99
First 12
λ> First 12 <> (First 30 <> (First 7 <> First 99))
First 12
On the other hand, there is no Monoid
instance provided for First
. No value can be supplied to First
that, when combined with a distinct second value, would result in the second value. Therefore mempty
, an "empty" value satisfying the identity law1, cannot be implemented for First
.
Typeclass hierarchies help users quickly distinguish how different types behave by communicating the operations they implement and the laws they satisfy. Observing where different types fall within these hierarchies is an easy way to discover intentionally omitted instances.2
TypeError
Using The 2016 release of GHC 8.0.1 introduced TypeError
for custom compile-time errors. Unlike with omitted instances, TypeError
enables explicit anti-instances:
{-# LANGUAGE GHC2021, DataKinds #-}
import GHC.TypeError (TypeError, ErrorMessage (Text))
class ReflexiveEq a where
reflexiveEq :: a -> a -> Bool
instance TypeError
(Text "Equality is not reflexive on Double: NaN /= NaN")
=> ReflexiveEq Double where
reflexiveEq _ _ = False
Triggering a compilation error with the above anti-instance can be seen below:
λ> reflexiveEq (1 :: Double) (1 :: Double)
<interactive>:30:1: error: [GHC-64725]
• Equality is not reflexive on Double: NaN /= NaN
• In the expression: reflexiveEq (1 :: Double) (1 :: Double)
Custom type errors enable user-defined error messages and provide concrete pointers for code documentation and reference in discussions. TypeError
improves developer experience by enhancing library accessibility, IDE tooltips, and user interactions with the compiler.
One downside of TypeError
is its implementation as a type-level function.3 This provides flexibility but makes it more difficult to understand when exactly a TypeError
will be thrown.
While TypeError
usage has been minor within the Haskell ecosystem, there are some very cool examples of them appearing in several libraries.4
Unsatisfiable
Introducing The upcoming GHC 9.8.1 release will bring an improved version of TypeError
called Unsatisfiable
.
{-# LANGUAGE GHC2021, DataKinds #-}
import GHC.TypeError (Unsatisfiable, ErrorMessage (Text))
instance Unsatisfiable
(Text "Halt! Functions cannot be compared for equality.")
=> Eq (a -> b)
Triggering a compilation error with the above anti-instance can be seen below:
λ> (\x -> x) == (\y -> y)
<interactive>:18:11: error: [GHC-22250]
• Halt! Functions cannot be compared for equality.
• In the expression: (\ x -> x) == (\ y -> y)
Anti-instances defined with Unsatisfiable
look nearly identical to those defined with TypeError
, but have some additional benefits. Unlike TypeError
, Unsatisfiable
is a typeclass and thus produces types of kind Constraint
.
λ> :kind TypeError
TypeError :: ErrorMessage -> b
λ> :kind Unsatisfiable
Unsatisfiable :: ErrorMessage -> Constraint
This results in more predictable error reporting and fixes buggy behavior seen when TypeError
is used with -fdefer-type-errors
. You can find more details on its advantages in the Unsatisfiable
GHC Proposal.
Tips & Tricks
Escape Hatch
What if you really need an operation from an outlawed instance? Whether facing an implicit anti-instance by omission or an explicit anti-instance by custom type error, the solution is to use a newtype.
Newtypes overcome anti-instances by creating a wrapper type upon which fresh instances can be created without polluting the underlying type.
For example, we can implement a Monoid
instance on UnsafeFirst
without worrying about the Monoid
anti-instance on First
:
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE GHC2021, DataKinds #-}
import Data.Semigroup (First)
import GHC.TypeError (Unsatisfiable, ErrorMessage (Text))
instance Unsatisfiable (Text "First lacks an identity element")
=> Monoid (First a)
-- | `First`, but with a `Monoid` instance
newtype UnsafeFirst a = UnsafeFirst
{ getUnsafeFirst :: First a }
deriving (Show, Semigroup)
instance Monoid (UnsafeFirst a) where
mempty :: UnsafeFirst a
mempty = error "certified law breaker"
This "works":
λ> UnsafeFirst (First 1) <> UnsafeFirst (First 3)
UnsafeFirst {getUnsafeFirst = First {getFirst = 1}}
λ> UnsafeFirst (First 1) <> mempty
UnsafeFirst {getUnsafeFirst = First {getFirst = 1}}
...at least until there's a law-breaking mempty
in the first position.
λ> mempty <> UnsafeFirst (First 3)
UnsafeFirst {getUnsafeFirst = First
{getFirst = *** Exception: certified law breaker
CallStack (from HasCallStack):
error, called at <interactive>:52:12 in interactive:Ghci7
Clearly, the above example is a bad idea. Carefully consider whether this workaround is a good idea and truly necessary in your case. There's likely a different representation of your data that can better achieve your goals.
For example, a different First
datatype exists in Data.Monoid
that wraps the Maybe
datatype.
newtype First a = First { getFirst :: Maybe a }
instance Semigroup (First a) where
(<>) :: First a -> First a -> First a
First Nothing <> b = b
a <> _ = a
instance Monoid (First a) where
mempty :: First a
mempty = First Nothing
Combining values under Data.Semigroup.First
means selecting the first value, while combining values under Data.Monoid.First
means selecting the first "non-empty" value.
λ> First Nothing <> First Nothing <> First Nothing
First {getFirst = Nothing}
λ> ((First Nothing <> First (Just 99)) <> First Nothing) <> First (Just 33)
First {getFirst = Just 99}
λ> First Nothing <> (First (Just 99) <> (First Nothing <> First (Just 33)))
First {getFirst = Just 99}
Verified Counterexamples
One of Haskell's strengths is its facility for making assumptions explicit.5 Rather than implicitly omitting impossible instances, Haskellers can communicate intent with anti-instances. Further, claims about why an anti-instance exists can be strengthened by incorporating machine-checked validations.
Using property-based testing with doctests, we can verify typeclass instances and anti-instances directly in our documentation. The presence of counterexamples from generated values makes it clear which anti-instances are legitimately law-breaking. Likewise, the absence of counterexamples from generated values makes it clear which typeclass instances are legitimately law-abiding.
Further Reading
For more on this topic, I highly recommend reading the Unsatisfiable
GHC Proposal and A story told by Type Errors.
Additionally:
For learners looking to level up their typeclass knowledge...
Advanced beginners and intermediate Haskellers looking to deepen their understanding of typeclasses and type-level techniques should check out Thinking With Types for a comprehensive tour. Other resources I've found interesting and useful include:
For more seasoned explorers...
Those with further interest in this area:
- See related GHC proposals like the recently accepted Deprecated Instances proposal and pending Custom Type Warnings proposal.
- For why typeclass laws require manual verification, see Rice's theorem and this StackOverflow answer. See the Verifying replicated data types and Quotient Haskell papers for examples using Liquid Haskell to statically verify typeclass laws and encode laws directly onto datatypes, respectively.
If there's further interest, I may write more on this topic. I have some thoughts to flesh out around tradeoffs, related proposals, more ergonomic law-checking, and deriving anti-instances.
Thanks for reading!
✌️
Footnotes
Footnotes
- Associativity Law: An operation is associative if parenthesizing its usage from left-to-right and right-to-left evaluates to the same value. Types with operations obeying the associative law can be given a
Semigroup
instance. - Identity Law: An operation has a neutral element, or identity, if there exists an element that can be combined with any other element without changing the other element. Types with an operation obeying the associative law and an identity element for that operation can be given a
Monoid
instance.
↩ ↩2Property Counterexample Example Associative:
(A <> B) <> C == A <> (B <> C)
newtype Midpoint = Midpoint Double deriving (Eq, Show) -- Bad! Law-breaking instance! instance Semigroup Midpoint where (<>) (Midpoint a) (Midpoint b) = Midpoint ((a + b) / 2) λ> (Midpoint 1 <> Midpoint 2) <> Midpoint 3 Midpoint 2.25 λ> Midpoint 1 <> (Midpoint 2 <> Midpoint 3) Midpoint 1.75
Left-to-right evaluation is different from right-to-left Max
,Min
,First
,Last
,NonEmpty
Associative + Identity:
Semigroup: (A <> B) <> C == A <> (B <> C)
Left Identity: e <> A == A
Right Identity: A <> e == A
-- Bad! Law-breaking instance! instance Monoid (NonEmpty a) where mempty = undefined λ> (10 :| [20, 30]) <> mempty :: NonEmpty Int 10 :| [20,30] λ> mempty <> (10 :| [20, 30]) :: NonEmpty Int *** Exception: Prelude.undefined CallStack (from HasCallStack): undefined, called at...
No "empty" value to serve as an identity element Sum
,Product
,All
,Any
- Associativity Law: An operation is associative if parenthesizing its usage from left-to-right and right-to-left evaluates to the same value. Types with operations obeying the associative law can be given a
The Semigroup-Monoid-Group hierarchy distinguishes types with an operation that is merely associative from those that can also be combined against an identity value and an inverse value.
Here are some classic typeclass hierarchies for implicit yet intentionally omitted instances:
- Typeclassopedia
- A Brief Guide to A Few Algebraic Structures
- A guide to the PureScript numeric hierarchy (Purescript)
NumHask
(Alternative numeric hierarchy for Haskell)
Counterexamples of Type Classes explores several anti-instance hierarchies in Purescript through counterexamples. ↩
Otherwise known as a type family. ↩
TypeError
has powered:mezzo
for type-safe (Western) music composition that enforces the rules of counterpointsilica
for more accessible optics with high-quality type errors
You can read more about the benefits of being explicit in my Fearless Tinkering is Functional blog series. ↩
Testing anti-instances against something like
monoidLaws
inquickcheck-classes
orhedgehog-classes
won't work. This is a good thing, as those checkers require operations from typeclass instances that we don't want to exist.One workaround is to provide a false implementation of typeclass operations as a record and pass it around manually like a typeclass would if it was handling the plumbing. This is the approach taken in Figure 3. That example's false implementation turned out to be problematic as it relied on non-generated values. Since my earlier point was more about the ability to co-locate testing and instances within the documentation, I've decided to leave the example as is (for now).
Two interesting things to explore would be a) making law-checking without a valid instance more ergonomic and b) making a fancier anti-instance wrapper that takes/witnesses a counterexample as input before returning an
Unsatisfiable
constraint. ↩