Skip to content
Tech News
← Back to articles

Type-checked non-empty strings

read original more articles
Why This Matters

This article highlights a novel technique in Haskell for enforcing non-empty string constraints at compile time, leading to improved build performance and safer code. By leveraging advanced GHC features like RequiredTypeArguments, developers can prevent invalid states and enhance software reliability, which is highly valuable for large, data-heavy applications in the tech industry.

Key Takeaways

This post is a Haskell koan. We’ll get to the background and motivation, but the goal here is to share a small and uncommon technique that we’ve employed, and enjoyed; perfect blog fodder.

In short, we wrote a type-checked non-empty string constructor, replaced thousands of equivalent TemplateHaskell calls, for a ~10% build-time improvement in a large and data-heavy package that made many calls to it.

invalidAfter :: NonEmptyText before, after, invalidBefore, = $$ (NonEmptyText.make "hello" ) before(NonEmptyText.make = NonEmptyText.make "hello" afterNonEmptyText.make = $$ (NonEmptyText.make "" ) -- ⇝ error during splice evaluation ... invalidBefore(NonEmptyText.make = NonEmptyText.make "" -- ⇝ type error: expected a non-empty string invalidAfterNonEmptyText.make

To make invalid states unrepresentable is a core design goal of the software at Bellroy. With that in mind, a type we often employ for textual data – of which we have a lot – is NonEmptyText . It is just what it sounds like: a value of this type is a string with at least one character.

The technique is a result of a convergence of GHC features over the past 15 years or so. In particular, RequiredTypeArguments , introduced in GHC 9.10, lets us pass a type-level string literal into a function as if it were a value. We can throw a custom type error message like "Expected a non-empty string" if we (at the type-level) see an empty string; like so:

type family IsNonEmptySymbol symbol :: Constraint where IsNonEmptySymbol "" = Unsatisfiable ( Text "Expected a non-empty string" ) IsNonEmptySymbol _ = ( ():: Constraint ) -- empty constraint is always satisfied -- Note previous syntax without RequiredTypeArguments: -- make :: forall symbol. IsNonEmptySymbol symbol => NonEmptyText -- with usage like `make @"hello!"` make :: forall symbol -> ( IsNonEmptySymbol symbol) => NonEmptyText symbolsymbol) = NonEmptyText (fromString (symbolVal ( Proxy :: Proxy symbol))) make symbol(fromString (symbolVal (symbol))) test :: NonEmptyText = make "hello!" testmake

Which, with the right LANGUAGE incantations, does actually work. This requires UndecidableInstances to work, which is not harmful in and of itself, but does open up the possibilities of what can go wrong .

Additionally, since IsNonEmptySymbol is a type family, it can’t directly be used like an ordinary typeclass constraint – for instance, it can’t be packaged into a Dict , or used with functions like Data.SOP.hcfoldMap ; it’s not something you can just “ask for an instance of” like an ordinary typeclass, despite returning a Constraint like one.

The final step then to this trick is writing IsNonEmptySymbol as a typeclass:

class IsNonEmptySymbol symbol symbol instance {-# OVERLAPPING #-} Unsatisfiable ( Text "Expected a non-empty string" ) => IsNonEmptySymbol "" instance IsNonEmptySymbol a -- make: same as above make :: forall symbol -> ( IsNonEmptySymbol symbol) => NonEmptyText symbolsymbol) = NonEmptyText (fromString (symbolVal ( Proxy :: Proxy symbol))) make symbol(fromString (symbolVal (symbol)))

... continue reading