In PureScript, you might have seen that some type classes are implemented in the compiler's Prim
modules, for various reasons such as better type inference, using information not available in the type system, efficiency of generated instances, avoiding redundant instances, etc. There may be many times when you also need to implement your own compiler type class for solving things not possible in PureScript currently, such as was the case with Row.Cons
, RowToList
, Symbol.Cons
, and more.
In this post, we'll look at what it looks like to add a compiler type class with the current state of the PureScript compiler. For our example, we'll add a type class that can tell us if a Symbol
contains a Symbol
and returns a result of kind
Boolean
.
First, all of the existing compiler type classes have a pattern associated with them, so we will want to add this to src/Language/PureScript/Constants.hs
with the proper Prim
submodule.
pattern SymbolContains :: Qualified (ProperName 'ClassName)
pattern SymbolContains = Qualified (Just PrimSymbol) (ProperName "Contains")
We'll want to add an entry to the appropriate submodule types list, so first we add to primSymbolTypes
:
, ( primSubName C.moduleSymbol "Contains"
, ( kindSymbol -:> kindSymbol -:> kindBoolean -:> kindConstraint
, ExternData
)
)
So we see here that we have declared here like the other types that we use the name "Contains", and it has a kind signature of Symbol -> Symbol -> Boolean -> Constraint
, meaning that a given constraint using Contains
will have these parameters.
Then we add this to the appropriate submodule classes map our type class data, for what the parameters should be referred to as and the functional dependencies involved in primSymbolClasses
:
-- class Contains (pattern :: Symbol) (symbol :: Symbol) (result :: Boolean)
-- | pattern symbol -> result
, (primSubName C.moduleSymbol "Contains", makeTypeClassData
[ ("pattern", Just kindSymbol)
, ("symbol", Just kindSymbol)
, ("result", Just kindBoolean)
] [] []
[ FunctionalDependency [0, 1] [2]
])
So here we have declared that the functional dependency of this class is that the pattern and the symbol determine the result, since we can use the pattern with the symbol to solve if the pattern is contained in the symbol.
To add solving for our type class, we should add it to src/Language/PureScript/TypeChecker/Entailment.hs
as a matched definition of forClassName
:
forClassName _ C.SymbolContains args | Just dicts <- solveSymbolContains args = dicts
And now we can define solveSymbolContains
like how many other functions for solving instances are defined. Like many, we split up the destructuring and handling of the returned type class dictionary type:
solveSymbolContains :: [Type] -> Maybe [TypeClassDict]
solveSymbolContains [arg0, arg1, arg2] = do
(arg0', arg1', arg2') <- containsSymbol arg0 arg1 arg2
let args' = [arg0', arg1', arg2']
pure
[TypeClassDictionaryInScope [] 0 EmptyClassInstance []
C.SymbolContains args' Nothing]
solveSymbolContains _ = Nothing
As this is a class with only a type class instance, we end up using EmptyClassInstance
like in other compiler solved classes.
Then our actual solver ends up being a matter of working with the Symbol
representation of the compiler and applying a text function to figure out if we should return true or false:
containsSymbol :: Type -> Type -> Type -> Maybe (Type, Type, Type)
containsSymbol patt@(TypeLevelString patt') sym@(TypeLevelString sym') _ = do
flag <- T.isInfixOf <$> decodeString patt' <*> decodeString sym'
pure (patt, sym, TypeConstructor $ if flag then C.booleanTrue else C.booleanFalse)
containsSymbol _ _ _ = Nothing
So at the end of the day, we take either the booleanTrue
or booleanFalse
constant types defined in Constants module to fulfill the Boolean
kind argument. And this is about it, where the core logic of this compiler type class has been implemented in roughly a dozen lines of code.
To have documentation for our class, we should add an entry for it in our docs for Prim
modules in src/Language/PureScript/Docs/Prim.hs
.
First, we should add an entry to the Prim
submodule. In our case, this will go to the primSymbolDocsModule
:
, modDeclarations =
[ symbolAppend
, symbolCompare
, symbolCons
+ , symbolContains
, symbolBreakOn
]
Then we'll define a Declaration
below:
symbolContains :: Declaration
symbolContains = primClassOf (P.primSubName "Symbol") "Contains" $ T.unlines
[ "Compiler solved type class for checking if a Symbol contains a Symbol."
]
And that's all we need for our docs. If you build the compiler at this stage and run pulp docs -- --format html
and open generated-docs/Prim.Symbol.html
, you will see the entry in the docs:
We should add a simple usage to our passing tests to make sure this works in the cases we want. To that purpose, we can add a test file to tests/purs/passing
called SymbolContains.purs
:
module Main where
import Effect.Console
import Data.Symbol (SProxy(..))
import Prim.Boolean as Boolean
import Prim.Symbol as Symbol
import Type.Data.Boolean (BProxy(..))
symbolContains
:: forall pattern sym result
. Symbol.Contains pattern sym result
=> SProxy pattern
-> SProxy sym
-> BProxy result
symbolContains _ _ = BProxy
-- inferred type:
resultContains1 :: BProxy Boolean.True
resultContains1 = symbolContains (SProxy :: SProxy "b") (SProxy :: SProxy "abc")
-- inferred type:
resultContains2 :: BProxy Boolean.False
resultContains2 = symbolContains (SProxy :: SProxy "z") (SProxy :: SProxy "abc")
main = log "Done"
In reality, I created this test file after building the compiler, so I used the IDE tools to help me generate those type signatures here. So in the test cases, the first tests that "abc" contains "b" and the second that "abc" does not contain "z". Nice!
Hopefully this has shown you that you can readily add some of your own compiler type classes to PureScript with minimal changes. If you have some use cases currently that suffer from some limitations in some way and you don't want to resort to error-prone code generation, you might consider forking the PureScript compiler to add a compiler type class that you might need, and maybe upstream those changes if they work out.
In my case, I have a bit of a need for this class along with the ability to break a Symbol
into its head and tail components so I can more correctly work with some type-level string parsing, but sky's the limit for what kind of things you need to do.
- PR for
Symbol.BreakOn
purescript/purescript#3383