Alexander Granin / Pragmatic Type-Level Design v.0.9.
1 2
Pragmatic
Type-Level Design
v.0.9.1
Alexander Granin
[email protected]
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 3
Author’s Foreword
Hello dear friend,
Thank you so much for your interest in Pragmatic Type-Level
Design!
As you may know, I'm also the author of Functional Design and
Architecture, a deep and novel exploration of applied, practical
programming with Haskell and other functional languages. In
FDaA, I cover high-level ideas, design principles, and best
practices for building robust applications, offering a
comprehensive source of knowledge about software
engineering. The aim is for readers to have everything they
need to design and implement applications effectively.
However, as I progressed with FDaA, it became clear that
there’s an untamed world of type-level concepts without
established development practices. That’s what inspired me to
write Pragmatic Type-Level Design—to address this gap and
approach the subject differently from other resources.
Type-level programming has been around for a while, with
many languages boasting rich type systems. It’s a domain
beloved by many, especially in Haskell, where types are one of
the language’s most compelling features. Haskell developers
often gravitate toward the mathematical foundations of types,
like Category Theory and Type Theory. But as I sought to
improve my understanding of types, I found myself constantly
struggling, frustrated, and confused. The academic focus on
math didn’t provide the answers I was looking for.
As a pragmatist, I wanted a clear, practical framework for
software design with types—from design principles to
implementation details. Yet, most resources left my questions
unanswered. They were either overly academic or too
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 4
fragmented to offer a coherent view. And while type-level
programming is challenging by nature, it’s even more so in
Haskell due to the disjointed and complex nature of the existing
materials. These resources often felt like folklore or scattered
wisdom rather than structured, practice-oriented knowledge.
Figure: Wisdom versus Knowledge
This is why I want to create this book. With Pragmatic
Type-Level Design, my goal is to provide a complete, practical,
and organized discipline for type-level programming, free from
unnecessary academic abstractions and focused on real-world
application. I know I can do it—I’ve already addressed the lack
of practical materials in software engineering and functional
programming with Functional Design and Architecture. I even
developed a methodology called Functional Declarative Design,
and its success exceeded my expectations.
I struggled with those overly complex concepts, not because I
couldn’t understand them, but because the traditional teaching
methods didn’t align with my perspective on types. If you feel
the same, this book is for you. It’s written in a fun, engaging
style, filled with practical examples, and free from unnecessary
mathematical complexity. You’ll find it immediately useful for
solving real-world business problems.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 5
This book offers a comprehensive view of software engineering
with types, capturing the true spirit of engineering. While you
don’t need to have read FDaA to get the most out of Pragmatic
Type-Level Design, doing so could certainly help.
LINK Alexander Granin, Functional Design and Architecture
(first version, self-published)
https://leanpub.com/functional-design-and-architecture
LINK Alexander Granin, Functional Design and Architecture
(second version, Manning Publications)
https://www.manning.com/books/functional-design-and-arc
hitecture
LINK Code and materials for Pragmatic Type-Level Design
https://github.com/graninas/Pragmatic-Type-Level-Design
I hope you’ll enjoy the book.
Best wishes,
Alexander Granin
About the LeanPub version
I’m still working on this book. I expect it to be 8 chapters long,
but the Table of Contents is still under construction. The current
estimation for PTLD is January 2025.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 6
Table of Contents
Part I The basics of type-level software design
1. Emergent type-level design
1.1. Approaching the type-level design
1.1.1. Types and values
1.1.2. Pragmatic Type-Level Design
1.1.3. Typed Forms diagrams
1.2. The foreshadowing of type-level design
1.2.1. Basics of generics
1.2.2. Basics of type classes
1.2.3. Design principles
1.2.4. Basics of type-level literals
1.3. Summary
2. Use case: simple extensibility
2.1. Extensibility
2.1.1. Extensibility mechanisms
2.1.2. Extensibility requirement
2.1.3. Extension points
2.2. Basically extensible application
2.2.1. Type class interface
2.2.2. Heterogeneous storage problem
2.2.3. Valuefication and existentification
2.2.4. Valuefied storage
2.2.5. Existentified storage
2.3. Summary
3. Use case: genericity and customization
3.1. Type-level genericity
3.1.1. Empty ADTs
3.1.2. Type applications
3.1.3. Ordinary and specific kinds
3.1.4. Custom type-level ADTs and kinds
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 7
3.1.5. Type-level lists
3.2. Type-level customization
3.2.1. Case-driven design methodology
3.2.2. Customizable type-level eDSL
3.3. Summary
4. Use case: enforcing correctness
4.1. Correctness is about meaning
4.1.1. Type-safe vs correct
4.1.2. Type-level vs correct
4.2. Static integrity
4.2.1. Strengthening the domain model
4.2.2. Static and volatile domain notions
4.2.3. Static operational integrity
4.2.4. Type-level validators
4.2.5. Static structural integrity
4.3. Summary
Part II Architecturing type-level applications
5. Application architecture
5.1. Approaching software architecture
5.1.1. Architecture levels
5.1.2. Double Usage Assessment practice
5.1.3. Two applications, same architecture
5.2. Application structure
5.2.1. Layered architecture
5.2.2. Project structure
5.2.3. Organizing type-level code
5.2.4. Application layer
5.3. Summary
6. Components design
6.1. Static and dynamic domain models
6.1.1. Separate type-level and value-level models
6.1.2. Granular Type Selector design pattern
6.1.3. Interpretation of static and dynamic models
6.1.4. Static materialization
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 8
6.1.5. Data Transfer Objects and serialization
6.2. Two functional interfaces
6.2.1. Properties of a true interfacing mechanism
6.2.2. Type class versus Free monad
6.2.3. Free monad interface
6.2.4. Type class interface and the Dynamic Payload
design pattern
6.3. Summary
Part III Advanced type-level design
7. Use case: type-level object-oriented programming
7.1. Multiparadigm approach
7.2. Glimpse of typed object-oriented programming
7.2.1. Zeplrog: the concept
7.2.2. The Expression Problem
7.3. Type-oriented object model
7.3.1. Property model
7.3.2. Static materialization and dynamic instantiation
7.3.3. Typed-Untyped design pattern
7.4. Summary
8. Use case: advanced extensibility
8.1. Advanced type-level design and extensibility
8.1.1. Domain modeling with empty parametrized ADTs
8.1.2. Type-level interfaces
8.1.3. Universal evaluation mechanism
8.1.4. Advanced extensibility and the Expression
Problem
8.1.5. Type-level combinatorial eDSLs and lambdas
8.2. Summary
Part IV Rosetta Stone
Appendixes
Appendix A Typed Forms diagrams
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 9
A.1 General conventions
A.2 Regular types, pairs, aliases, lists
A.3 Simple ADTs
A.4 Parametrized types
A.5 Generics specification syntax
A.6 Type classes and instances
A.7 Kinds
A.8 Type promotion
A.9 Type families
A.10 HKD template
Appendix B Existential Fight Club
B.1 Haskell, existentification
B.2 Haskell, valuefication
B.3 Rust, existentification
B.4 Rust, valuefication
B.5 C++, object-oriented variant
B.6 C++, valuefication
B.7 Scala 2, existentification with implicits
Appendix C The Mythologized Correctness
C.1 Type safety
C.1.1 Generic type safety
C.1.2 Descriptive type safety
C.2 Technical correctness
C.2.1 Correctness of data structures and algorithms
C.2.3 Correctness of data models
C.2.3 Correctness of languages
C.3 Conclusion
Appendix D Extensible value definition model in the Zeplrog project
D.1 Trichotomy: extensibility, type safety, and simplicity
D.2 Extensible value model of Zeplrog
D.2.1 Properties and scripts
D.2.3 Variables, values, and tags
D.2.3 Extensibility with open type families
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 10
Appendix E Event-based architecture in the Minefield game
E.1 Minefield application architecture
E.1.1 Event-based actor model
E.1.2 Minefield type-level domain model
E.1.3 Domain-level noun-verb extensibility
E.2 Minefield application implementation
E.2.1 The MVar request-response pattern
E.2.2 Events and queues
E.2.3 Actor model implementation
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 11
Part I
The Basics of Type-Level
Software Design
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 12
Chapter 1
Emergent type-level design
This chapter covers
▪ How to program with types and values
▪ What is Pragmatic Type-Level Design
▪ Basics of the type-level programming
I like cellular automata very much. What a wonderful invention
it is - the glory Game of Life by John Conway! The impressive
depth of this cellular automaton emerges from a very simple
set of rules, which boggles the imagination when you see these
worlds for the first time. Just two rules on how a square field of
neighboring cells evolves step-by-step – and you’re getting a
really complex universe full of strange beasts that travel,
oscillate, collide, merge, diverge, born and die.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 13
Figure 1.1 Metapixel: Game of Life built in Game of Life (fragment)
Game of Life is Turing-complete, so it won’t be a mistake to call
it an esoteric 2D graphical programming language. Coding in
this language has a particular charm. On the infinite quadratic
grid, we turn cells on and off thus painting the program
intended to mind some cellular business. The rules, therefore,
act like a computer that recognizes this monochrome program
and transforms the input world into the output world, one
evolution after another. We prepare the pattern, run the world,
and, after a certain number of iterations, get the result
hopefully having some desired properties.
The iterative evolution of the world is deterministic. Like in pure
functional programming, starting with identical cell patterns
leads to identical outcomes, but unlike programming, predicting
the result from the initial pattern is much more difficult, even in
several iterations. Due to this, Game of Life programs are very
sensitive to bugs. Any mistake, any misplaced cell – and our
carefully arranged world blow up to the state we didn’t expect.
What was a recognizable pattern becomes chaos full of dancing
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 14
cellular demons. What was meaningful information corrupts and
decays to elementary pieces or, sometimes, to nothingness,
even.
This is why programming such a system is quite a challenging
task. Knowing both the rules of Game of Life and the fact that
it’s Turing-complete doesn’t help here. GoL programming is still
a trial-and-error process, and to ease this process, many best
practices have been produced. Cellular design patterns, ready
solutions, configurable libraries of mechanisms, debugging
techniques – everything to deal with the complexity that
naturally emerges from the simple base premises.
Sounds familiar, right? Indeed, everything said can be applied
to type-level programming, too. Both started as computer
science topics. Both promise a lot of joy to play with. Like a
cellular program, type-level one starts with small, simple
concepts but tends to blow into a tricky, complicated
contraption that is difficult to observe and predict in behavior.
Like in Game of Life, a tiny yet wrong change in the type-level
code may cause unwanted consequences.
Being a sharp tool, type-level programming can easily strike
back and will certainly do so if treated naively. In the face of
complexity, the evolution of GoL programming has led to having
best practices, and so should type-level programming. Building
big yet manageable programs with massive involvement of
types should be done consciously, with good, reasonable,
controllable approaches. In other words, we need type-level
programming to be engineering, not just a form of art or blind
hacking. Hence, Pragmatic Type-Level Design. With the ideas
from this book aimed to be pragmatic, many software worlds
will be saved from painful and expensive death by a thousand
emergent complexities.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 15
1.1 Approaching the type-level design
Emergent systems are all the same. Given a small set of simple
pieces and a rule on how to combine them, you can build a
really complicated mechanism that serves some needs.
The so is true for biological life with its DNA foundations. Just
four nucleotides constitute every double spiral, but the number
of lifeforms emerging from this encoding boggles our
imagination.
The so is Game of Life. A rule for a 2D grid of on-off cells every
child can understand leads to worlds we’ve never seen before.
The so is type-level design.
DEFINITION Type-level design: the design of software
with a focus on applying various type-level features and
tricks to solve the usual programming problems in
compile-time. Type-level design is characterized by
preferring type-level solutions over value-level ones,
generic code over specific code, and compiler-time
evaluation over runtime evaluation. Type-level design
results in mechanisms and business logic expressed with
type-level code.
DEFINITION Type-level feature: any language feature
related to types and type transformations.
DEFINITION Value-level design: the opposite of
type-level design. Programming tasks and business logic are
expressed with values and functions. Types mostly describe
the data but do not serve any additional purpose.
Value-level design can also be called data-oriented
programming.
TIP Real projects in statically typed languages are always a
mix of type-level and value-level solutions.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 16
Type systems in such languages as Haskell and Scala are rich
and full of sophisticated ideas, but even a subset of basic
features may help us in our day-to-day needs. You’ll be
surprised how much you can achieve without abusing every
single shiny thing in your type system, and how bit yet useful
code it’s possible to build. This means that while being a broad
topic, type-level design is also an emergent system: a small
number of carefully chosen ideas gives you a lot of possibilities.
But before submerging ourselves into this wild ocean, we
should establish the ground and discuss what programming
with types and values is like.
1.1.1 Types and values
If we were asked to create a program with a toy functionality,
our engineer’s reasoning would not go much beyond simple
decisions. A minimal Game of Life application would be a good
example of this to start. We could get the following functional
requirements:
▪ Game of Life is a console application.
▪ The application accepts the number of iterations to do with
the world.
▪ The application accepts the input file of the initial world in
a predefined text format with a maximum size is 100
kilobytes.
▪ The application accepts the output file name for the final
world in a predefined text format.
▪ The application performs the evaluations of the world and
prints the results into the output file.
These are all functional and non-functional requirements for
now. Nothing here is said about performance, and there is no
requirement to track the intermediate worlds during the
evolution. We can merely store the current world in an
array-like or map-like data structure in memory. This might be
suboptimal because the worlds often grow indefinitely in size,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 17
but at least this technical decision enables us to write the
application quickly.
During the usual domain modeling, we’ll end up with a typical
code that relies on some data types to store the worlds.
Nothing beyond that, nothing fancy or generic, just some types
to denote some runtime values our program should operate
with. The following Board maps indexes to the states of cells
(see figure 1.2 for a Typed Forms diagram):
import Data.Map as Map
type Coords = (Int, Int)
data Cell = Alive | Dead
type Board = Map Coords Cell
Figure 1.2 Typed Forms diagram for Board
TIP Full guide on Typed Forms diagrams is available in
Appendix A: Typed Forms diagrams.
What can be simpler than that? Just a two-dimensional key that
points to a specific cell on the board. Every cell here is encoded
directly, but enumerating dead cells is not necessary. The
following glider pattern contains five alive cells:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 18
glider :: Board
glider = Map.fromList [((1, 0), Alive),
((2, 1), Alive),
((0, 2), Alive),
((1, 2), Alive),
((2, 2), Alive)]
The shape of this glider is presented in figure 1.3:
Figure 1.3 Glider
The initial application code may look like the main function
having all the read-write file operations with some constants
pre-hardcoded:
loadFromFile :: FilePath -> IO Board
saveToFile :: FilePath -> Board -> IO ()
iterateWorld :: Int -> Board -> Board
main = do
board1 <- loadFromFile "./data/glider.txt" #A
let board2 = iterateWorld 5 board1 #B
saveToFile "./data/glider_5th_gen.txt" board2 #C
#A Load the initial world
#B Do 5 steps
#C Save the final world
Types here serve a descriptive purpose. The loadFromFile
function returns a value of Board, iterateWorld, then uses
the value for getting the next value which is the fifth generation
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 19
of the world. The type Board itself doesn’t do anything except
make our values better readable and understandable. We could
stick with the bare Map type instead and have the same
functionality because the types are just synonyms:
loadFromFile :: FilePath -> IO Board
loadFromFile :: FilePath -> IO (Map Coords Cell)
iterateWorld :: Int -> Board -> Board
iterateWorld :: Int -> Map Coords Cell -> Map Coords Cell
Nothing has changed here, so we can conclude that the Board
type identifier doesn’t play that much role in our code. We only
have it because it’s more convenient, but otherwise, it provides
no extra meaning.
Some new meanings could be introduced if we start wrapping
this type synonym into a newtype (see figure 1.4 for a Typed
Forms diagram):
newtype GoL = GoL Board
iterateGoL :: Int -> GoL -> GoL
TIP Other languages have similar features to Haskell’s
newtype. See Part IV: Rosetta Stone for more info.
Figure 1.4 Typed Forms diagram for GoL
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 20
In Haskell, a newtype wrapper is compile-time distinguishable
from the nested type. Since now, the two types (like GoL and
Board) can not be used in the same contexts, although the
actually stored boards may eventually become identical.
A common example of this would be a person's first and last
name. It should be a character string type, but in projects,
string types are everywhere and mean a lot of things. So we do
the newtype encapsulation:
newtype FirstName = FirstName String
newtype LastName = LastName String
It’s now difficult to confuse the two with FilePath, which
happened to be of type String as well:
type FilePath = String
lastName :: LastName
lastName = LastName "O'Neill"
lastFileName :: FilePath
lastFileName = "last_file_name.txt"
main = writeFile lastName "McCarthy" -- compile-time error
The trick helps to save some worlds from annihilation at the
cost of little inconvenience and boilerplate. We have to unpack
the newtype to access its contents:
writeLastName :: FilePath -> LastName -> IO ()
writeLastName path (LastName name) #A
= writeFile path name
main = writeLastName lastFileName lastName
#A Unpacking by pattern-matching
It’s not possible to pass FirstName into this writeLastName
function, so without additional mechanisms, this code is too
specific and rigid.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 21
This feature alone doesn’t let us climb much higher from the
usual code with basic types and values. Our reasoning still
revolves around how to transform values in the runtime and
doesn’t touch on how to modify the values during the compile
time. We don’t have any transitions of types to values and
back, as well as we don’t have any types transformed to any
other types, – the kind of operations that constitute the core of
type-level design. In our case, types describe and values labor
– and nothing beyond that.
You can do everything with this good old style of programming.
No need to bring extra meanings into the types; just call
functions, process data, express your domain in ADTs, and
enjoy the full power of regular functional programming. If you
need best practices for this path, you may want to follow my
methodology I call Functional Declarative Design, FDD for short.
It covers a lot of this style of programming and provides you
with many useful ideas on how to make your functional code
good. Consider learning the methodology in my Functional
Design and Architecture book:
LINK Alexander Granin, Functional Design and Architecture
https://www.manning.com/books/functional-design-and-arc
hitecture
Alternatively, you can read my introductory article:
LINK Alexander Granin, Functional Declarative Design: A
Comprehensive Methodology for Statically-Typed
Functional Programming Languages
https://github.com/graninas/functional-declarative-design-
methodology
Going type-level should, however, be more type-involving in
order to make the code more generic and abstract. This
certainly makes code more sophisticated and often less
obvious. Type-level features in all languages bring a significant
syntax and semantics overhead. So refraining from value-level
programming and shifting to type-level one should be properly
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 22
justified. There should be a convincing reason why doing this, a
real goal that reflects real, not imaginary, business needs. In
other words, there should be pragmatism in going type-level;
otherwise, we risk trading too much complexity for nothing.
1.1.2 Pragmatic Type-Level Design
Resources on types exist in numbers. Many of them are trying
to answer the question, “Why is the type-level stuff cool?”.
Sometimes they are trying to teach you Math instead of
teaching you writing useful software. The authors are keen to
show you the beauty of type-level tricks and share the related
enthusiasm around type-level mind games.
Pragmatism comes from a different side. It addresses these
questions: “How to apply type-level design for real tasks?”,
“How to achieve business goals and not exceed the complexity
budget?”, “Why this particular type-level solution is better than
a value-level one?”. Here are my answers when the type-level
design looks good:
▪ better ways to express critical business domains;
▪ lesser bugs and greater guarantees for code correctness;
▪ lesser need for extensive testing;
▪ reduced boilerplate;
▪ more extensible code;
▪ more reusable generic code;
▪ pre-calculating things in compile time;
▪ code generation;
▪ performance optimizations and zero-cost abstractions.
The reasonable approach should be about achieving some
meaningful result that is considerably better when it’s heavily
typed. The complexity of the type-level code may still strike
back eventually, so the possible refactorings should not lead to
the whole application rewrite. Such solutions are often difficult
to implement and understand, and they tend to become cryptic
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 23
even for the author after a while. Sometimes you feel like the
protagonist of the “Flowers for Algernon” novel when his
cognitive abilities collapse and he becomes unable to
understand his own writings.
My methodology, Pragmatic Type-level Design (PTLD),
addresses these issues. It acknowledges the need to control
accidental complexity and weigh all the cons and pros of a
type-level solution against other ones.
DEFINITION Essential complexity: inherent, inseparable
complexity of a business domain that reflects its essence.
It’s impossible to program a domain and avoid bringing
essential complexity into the project.
DEFINITION Accidental (incidental) complexity: the
complexity of tools and design decisions used to implement
a business domain and make it work. Solutions and
approaches vary in their accidental complexity in the given
contexts. Some solutions bring more accidental complexity,
some bring less under similar circumstances. The more
accidental complexity in the project, the more expensive it
is to evolve it, and the bigger risk of it to fail technically.
Accidental complexity is the main enemy of Software Design,
including Type-Level Design. Managing complexity and taming
it so it does not ruin the project long-term is our software
architects’ meta-goal. Accidental complexity tends to grow if
we don’t do anything to keep it low. Arguably, the more
type-level features are used, the steeper the growth of
accidental complexity in the project with time. Figure 1.5
brings this point into a plot:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 24
Figure 1.5 Accidental complexity of type-level and value-level solutions
We should be careful in our steps because type-level
programming has a carriage of risks attached to it:
▪ Overengineering. Chosen design solutions have an
inadequately high accidental complexity that will damage
the project’s budgets and long-term health a lot.
▪ Overcomplicating the code. This means making the code
difficult to understand and modify by choosing bad names,
and introducing too abstract, too generic solutions.
Overcomplicating the code often means dissolving the
business domain in the auxiliary mechanisms resulting in a
complete inability to recognize domain notions and
behavior in the code.
▪ Invalid design decisions. Type-level programming offers
many ways to do the same thing. Inappropriate solutions
can suddenly become an obstacle to further development
of the code. They may result in a complete rewriting of
significant parts of the project or abusing hacks that could
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 25
make the situation even worse.
▪ Pleasuring own narcissism. Type-level programming,
generic programming, and metaprogramming may be very
tempting for people who focus on their own intellectual
pleasure and forget about why they are hired in the first
place. It’s so easy to substitute business goals with the
desire to show our own smartness and the very ability to
mangle with high-level complex ideas, but we as software
engineers should always be honest and technically correct
in justifying our decisions.
To avoid these risks and stay rational, I offer the following
general principles within the PTLD methodology:
PRINCIPLE I Occam’s razor. It’s better to stick to a limited
set of features to cover 95% of cases and not introduce
extra features without the real need.
PRINCIPLE II No perfectionism. No need to make 100%
of the code perfect, correct, and beautiful. If covering all
the cases with a type-level code promises to explode in
complexity, it’s better to try other solutions instead.
PRINCIPLE III Dumb but uniform. A single, even dumb
methodology of doing everything uniformly is more valuable
than a brand-new shiny solution for every next problem.
PRINCIPLE IV Pragmatism. Beauty and math correctness
are of less value than an imperfect but working system.
Shortcuts and tradeoffs can be traded for less accidental
complexity. Design should always pursue real business
goals.
Pragmatic Type-Level Design, therefore, is a lighthouse in the
darkness, a map of treasures that will guide you through the
wild and untamed world full of scary beasts and dangerous
temptations.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 26
Figure 1.6 compares the three methodologies: Object-Oriented
Design (OOD), Functional Declarative Design, and Pragmatic
Type-Level Design.
Figure 1.6 Three Software Design Methodologies
We’ve already discussed some general principles, and are yet to
discover more. In fact, all the design principles we know in OOD
and FDD have a good chance of being suitable for PTLD. We’ll
meet many of them soon: SOLID principles, low coupling and
high cohesion, divide and conquer, and many others. This
diagram also tells us that high-level technical solutions follow
directly from these principles and only then do the
implementation details. Figure 1.7 highlights the principles for
FDD and PTLD:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 27
Figure 1.7 Design principles and ideas to keep the complexity low
I especially look forward to talking about type-level
domain-specific languages as my favorite way to express
business domains and beat the accidental complexity at the
same time. Consider this, with no explanations:
type GameOfLifeStep = Step
[ StateTransition 0 1 [ CellsCount 1 [3]] -- "Born"
, StateTransition 1 1 [ CellsCount 1 [2,3]] -- "Survive"
, DefaultTransition 0
]
It’s all Haskell’s types describing some logic for a cellular
automaton on the pure type level. We’ll come here eventually.
However, before jumping this high, we will need to learn some
basic type-level tricks and ideas.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 28
1.1.3 Typed Forms diagrams
Learning type-level stuff may be quite challenging. I know this
well. It’s really easy to get lost in all those definitions and
interconnections, especially when the syntax of the host
language is awkward. In this sense, Haskell is not C++,
certainly, but sometimes the sophistication of the syntax and
semantics exceeds all reasonable limits.
This is why I’ve developed a special visual language of
structured diagrams for my PTLD methodology. I call it Typed
Forms, and I’m sure it will serve two good goals for us: making
the explanations clearer and making the book aesthetically
appealing. I’ll teach you this diagram language as the time
comes. You can also consult the full description in Appendix A:
Typed Forms diagrams.
This is an example of such a diagram:
Figure 1.8 Typed Forms diagram
It looks cool, do you agree?
1.2 The foreshadowing of type-level design
Good old Haskell 2010 introduces a lot of type-level features.
Type definitions, type classes, type variables, constraints, kinds,
phantom types, and algebraic data types. With dozens of modern
GHC extensions, it becomes orders of magnitude more powerful.
Type-level literals, type families, multi-parameter type classes,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 29
generalized algebraic data types, different options for type-level
polymorphism, et cetera, et cetera – it’s too long to enumerate all
these tools, what to say about the explosion of all possible ways to
combine them. This is why I believe the field of type-level
programming is a pure art, yet everyone does it differently. We’re
going turn this art into engineering so it can be applied to many
various tasks uniformly. But we have to learn the fundamentals
first.
I’m about to demonstrate two important features: type classes and
type-level literals, which are at the heart of type-level design. We’ll
introduce many other type-level features as needed.
1.2.1 Basics of generics
Let’s return to the Game of Life application. I have some news:
the functional requirements have suddenly changed, as often
happens in the real world. We’re now mandated to program the
code that supports all possible Life-like cellular automata, not
only the one John Conway has invented and described.
Remember the GoL type that is a wrapper over the Board
type? Here we have three of them for various rules:
newtype GoL = GoL Board
newtype Seeds = Seeds Board
newtype Replicator = Replicator Board
All three are Life-like rules which means they all work on a 2D
grid with 2-state cells and use the 8-cells neighborhood. The
rules of how cells turn on and off differ, and we normally don’t
want the Seeds rule accidentally applied to the Replicator
world. With the newtypes, we prevent this mismatch, although
we have to have several separate step functions for each:
golStep :: GoL -> GoL
seedsStep :: Seeds -> Seeds
replicatorStep :: Replicator -> Replicator
We’ve obtained some safety by appending the extra knowledge
to the initial Board type. This slight improvement was nice and
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 30
easy, and the compiler doesn’t care about one more newtype or
one less. But we are not machines. There are 2^18 = 262144
possible Life-like rules, and only a tiny fraction of these are
investigated. Imagine we have to add more and more wrappers
when more cool automata emerge from the community of
lifers:
newtype Diamoeba = Diamoeba Board
newtype HighLife = HighLife Board
newtype DayAndNight = DayAndNight Board
diamoebaStep :: Diamoeba -> Diamoeba
highlifeStep :: HighLife -> HighLife
dayAndNightStep :: DayAndNight -> DayAndNight
Wow, much safety, so boilerplate. Iteration functions will breed
too:
iterateGoL :: Int -> GoL -> GoL
iterateReplicator :: Int -> Replicator -> Replicator
...
On a closer look, however, these functions are all identical if we
presume they use a function for a single step of a particular
automaton:
golStep :: GoL -> GoL #A
golStep = ... #B
iterateGoL :: Int -> GoL -> GoL
iterateGoL n gol | n == 0 = gol
iterateGoL n gol | n > 0 =
head
(drop 5 #C
(iterate golStep gol)) #D
iterateGoL _ _ = error "Invalid iteration count"
#A Game of Life step
#B Game of Life rule here
#C Infinite list of iterated worlds
#D Call of the step function
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 31
The iterateReplicator function will have the same body, up
to the identifiers. This is where generics come out. We can
move all this logic into an abstract function that accepts a step
of any cellular automaton function and does the same:
iterateWorld :: (ca -> ca) -> Int -> ca -> ca
iterateWorld step n world | n == 0 = world
iterateWorld step n world | n > 0 =
head (drop 5 (iterate step world))
iterateWorld _ _ _ = error "Invalid iteration count"
It doesn’t matter what the ca type really is as long as the step
function is provided. No need to know about the internal
structure of the factual ca type because this knowledge is
conveniently encapsulated and utilized within the step
function. We’ve even achieved some type safety here. It’s
impossible to apply the step function to the wrong world:
glider' :: GoL
glider' = GoL glider
> iterateWorld golStep 5 glider' -- OK
> iterateWorld seedsStep 5 glider' -- compilation error
DEFINITION Type safety: inability to perform wrong type
conversions, to construct incorrect types, or to combine
types and values that are not supposed to be combined.
This works because all the four ca type variables (stands for
“cellular automaton”) should be of the same type within a
single invocation of iterateWorld.
TIP Other languages have generics as well. See Part IV
Rosetta Stone for more info.
What to do with other useful functions that need to know about
the internals? This is the saving function:
saveToFile :: FilePath -> Board -> IO ()
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 32
We certainly don’t want to produce many separate methods like
saveGoLToFile, saveSeedsToFile, and so on, and at the
same time, we can’t generalize it yet:
saveToFile' :: FilePath -> ca -> IO ()
We know that all the newtypes wrap the same Board type, but
this function has no idea about that, and it can’t see through
the ca type variable here. The value of this unknown ca type is
opaque to the function, and there are no hints on what this is if
fully specified. This means that our generalization is a bit
limited, so we have to find another way.
1.2.2 Basics of type classes
Type classes will help us to generalize automata functions in a
more rigorous way. For now, we’ve observed the following
pieces of each automaton:
▪ type that distinguishes one automaton from another;
▪ specific step function that performs the corresponding rule.
We’ve also found that for the saveToFile function to work, we
just need to unwrap the Board value from the automaton type.
This could be done pretty straightforward:
saveToFile'' :: (ca -> Board) -> FilePath -> ca -> IO ()
saveToFile'' unwrap file world = saveToFile file (unwrap world)
And then:
unwrapSeeds :: Seeds -> Board
unwrapSeeds (Seeds world) = world
seedsWorld :: Seeds
seedsWorld = Seeds glider
> saveToFile'' unwrapSeeds "seeds.txt" seedsWorld
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 33
I would however argue that this design, while being completely
valid, forces us to stay on the value level and carry all those
helper functions. But now we’re ready to introduce a type class
interface for any possible Life-like cellular automaton. It will
only have three methods:
Listing 1.1 Type class interface for cellular automata
class Automaton a where
step :: a -> a
wrap :: Board -> a
unwrap :: a -> Board
We’re implementing it differently for the newtypes GoL, Seeds,
Replicator, and so on. This is perfectly sensible: the types
are distinct and unique, so there can be a unique instance of
this type class for each. If we tried to instantiate the type class
with Board several times, it wouldn't work.
TIP Instances of type classes will clash as long as they are
made for the same type and are imported into the same
module. Type class instantiation has other hidden pitfalls, so
consider learning about them from other sources.
LINK Vitaly Bragilevsky, Haskell in Depth
https://www.manning.com/books/haskell-in-depth
TIP C++, Scala, PureScript, and some other languages have
mechanisms similar to type classes. See Part IV Rosetta
Stone for more info.
Listing 1.2 Two separate implementations of the type class
-- module GameOfLife:
newtype GoL = GoL Board
instance Automaton GoL where #A
step = golStep
wrap board = GoL board
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 34
unwrap (GoL board) = board
-- module Seeds:
newtype Seeds = Seeds Board
instance Automaton Seeds where #B
step = seedsStep
wrap board = Seeds board
unwrap (Seeds board) = board
#A Implementation for GameOfLife
#B Implementation for Seeds
A Typed Forms diagram is presented in figure 1.9:
Figure 1.9 Type class and instance
Now, given these instances, our generic functions become more
convenient to use because we don’t need to pass helper
functions anymore. Everything needed can be requested from
the specific instance of this Automaton type class, and our
right to do so is written in the type declarations:
iterateWorld :: Automaton ca => Int -> ca -> ca
loadFromFile :: Automaton ca => FilePath -> IO ca
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 35
saveToFile :: Automaton ca => FilePath -> ca -> IO ()
All these methods know that the actual type that comes instead
of ca must have step, wrap, and unwrap implemented, so it’s
legit to call these functions when needed:
loadFromFile :: Automaton ca => FilePath -> IO ca
loadFromFile file = do
(board :: Board) <- loadBoardFromFile file
pure (wrap board) #A
#A Method ‘wrap’ from the type class
Finally, we can explicitly specify what implementation we’re
dealing with. In the following snippet, the compiler will deduce
those functions should come from the instance related to the
GoL type:
gol1 :: GoL #A
<- loadFromFile "./data/GoL/glider.txt" #B
let gol2 = iterateWorld 5 gol1
saveToFile "./data/GoL/glider_5th_gen.txt" gol2
#A Explicit type specification
#B Generic function call
This is how we got some extensibility with type classes. It’s now
possible to add more cellular automata without updating the
generic functions. The new instance doesn’t even have to be a
newtype around Board. For example, I could represent my
world with an associated list instead of the dictionary:
data Diamoeba = Diamoeba
{ diamoebaBoard :: [((Int, Int), Cell)]
, diamoebaWorldName :: String
}
Additionally, I’ve put another field into this ADT to denote the
name of the world. This field will not make any obstacle for the
Automaton mechanism, although it doesn’t help that much. We
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 36
should be prepared that the name may get lost or ignored
eventually:
instance Automaton Diamoeba where
step = diamoebaStep
wrap board = Diamoeba (Map.toList board) "" #A
unwrap (Diamoeba board _) = Map.fromList board #B
#A Default empty name is provided
#B The name is lost
Otherwise, the design seems to be valid.
Or does it?
Not quite. It has some more serious flaws and limitations and
even violates a pair of design principles, which is not good.
Let’s talk about that.
1.2.3 Design principles
Look at this tiny innocent type class again. How can this sweet
child violate something?
class Automaton a where
step :: a -> a
wrap :: Board -> a
unwrap :: a -> Board
In fact, it doesn’t respect the two SOLID principles: the Single
Responsibility Principle (SRP) and the Interface Segregation
Principle (ISP).
SRP says that an entity should have only one responsibility.
Automaton has two: progressing the world with step and
accessing the Board value with wrap and unwrap. What about
these two, having them in the type class violates the ISP
principle as they are domain-unrelated. The principle
encourages us to group responsibilities by means of separate
interfaces and not mix different levels of abstractions which we
clearly did here.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 37
Design principles such as SOLID highlight design smells and
allow us to argue about issues reasonably. In contrast to a
common misbelief (that persists all across the development
community), SOLID principles, first coined by Robert Martin,
are not theory, they are practical and quite useful.
In our design process, we do not just hack the code because
this would be blind and anti-engineering. When we design, we
are guided by high-level arguments to sense if our current idea
is good enough or not. SOLID principles, and some others such
as “low coupling, high cohesion” being treated correctly,
improve the overall quality of the code.
▪ Single Responsibility principle (SRP). An entity should
address only one responsibility at a time.
▪ Open-close principle (OCP). The system should have stable
interfaces, and adding more implementations or new
behavior should not affect the agreed contracts of behavior
(open for extension, closed for modification).
▪ Liskov Substitution principle (LSP). Multiple
implementations are interchangeable on the fly without
breaking the client code.
▪ Interface Segregation principle (ISP). Responsibilities
should be spread across focused and coherent interfaces.
▪ Dependency Inversion principle (DIP). Business logic
should depend on interfaces only, not on the actual
implementation, which is provided later in the interpreting
process.
▪ Low coupling, high cohesion. An entity should depend on
the bare minimum of external, unrelated entities (low
coupling). It should contain only what it needs and should
not have extra responsibilities that are unnatural to it (high
cohesion). This principle comes from another set of
principles known as GRASP (General Responsibility
Assignment Software Patterns).
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 38
These principles have been initially cast for object-oriented
design, but they are actually universal. In my FDaA book, I’ve
demonstrated their validity for functional programming and how
this makes my FDD methodology rich and powerful. Certainly,
Pragmatic Type-Level Design wouldn’t be pragmatic and
wouldn’t be a design without these principles, and you’ll see
how to apply them to the level of types.
For now, let’s revisit the newtypes approach because it’s
suboptimal and brings too much boilerplate into the code. We’ll
fix this by introducing a new world type and utilizing another
interesting feature: type-level literals. The new design will be
good enough for the further improvement of the Automaton
mechanism that follows in the next chapter.
1.2.4 Basics of type-level literals
The Board type fits our needs well, but defining the newtypes
doesn’t seem convenient. Remember, we could have literally
any world type:
newtype GameOfLife = GameOfLife Board
newtype Replicator = Replicator Board
data Diamoeba = Diamoeba
{ diamoebaBoard :: [((Int, Int), Cell)]
, diamoebaWorldName :: String
}
We’ll sacrifice this possibility for a while. There will be only one
type predefined for all the automata, similar to this:
newtype CellWorld = CW Board
If we try to make the actual automata just type synonyms, we’ll
encounter a problem.
type GoL = CellWorld
type Seeds = CellWorld
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 39
These two types are literally the same, it’s just two identifiers
for the same type. Going ahead, this won’t work with the
Automaton type class as we can’t instantiate it twice. We
should make them very distinct. One way to do it is
parametrization on the type level, for example, with type-level
strings. So there will be a parametrizable CellWorld template
and specific types for automata that differ from each other on
the type level.
First, this is how specific worlds look like now:
{-# LANGUAGE DataKinds #-}
type GoLRule = "Game of Life" -- DataKinds used here
type SeedsRule = "Seeds" -- DataKinds used here
type GoL = CellWorld GoLRule -- Game of Life
type Seeds = CellWorld SeedsRule -- Seeds
TIP Type-level strings are types, and they are distinct if
the content differs.
TIP Type-level strings require the DataKinds GHC
extension to be enabled. With this extension, the
compiler treats bare strings as types in the corresponding
places.
TIP Type-level strings are not unique to Haskell. See Part
IV Rosetta Stone for more info.
Second, we should redesign the CellWorld type. Just giving it
one arbitrary type parameter would not be enough:
newtype CellWorld arbitraryType = CW Board
The concrete types coming into this parameter are not
arbitrary, they are type-level strings. This must be reflected in
the definition of CellWorld for two reasons: it’s a sort of
documentation; the compiler must know this to make a proper
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 40
type inference. In Haskell, we assign a special kind Symbol to
this type parameter:
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}
import GHC.TypeLits ( Symbol )
newtype CellWorld (rule :: Symbol) #A
= CW Board
#A DataKinds + KindSignatures GHC extensions used here
The corresponding diagram is this:
Figure 1.10 Typed Forms diagram for CellWorld
TIP Kinds restrict types to have some shape. In the
modern Haskell extensions, kinds have been replaced by
the idea of “types of types,” and this made the whole
type system even more complicated. In order not to be
overwhelmed, we’ll keep the old terminology for now.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 41
NOTE Two extensions here, DataKinds and
KindSignatures, are responsible for the (rule ::
Xyz) form of specification. Check this out:
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE DataKinds #-}
import GHC.TypeLits (Symbol)
data StandardHaskell a = Test0
data NeedKindSignatures (a :: *) = Test1
data NeedKindSignaturesAndDataKinds (a :: Symbol) = Test2
For now, we have no other choice than just following the
recipe without much explanation. We’ll return to these
extensions and declarations later.
NOTE The CellWorld type has the rule type
parameter, but the CW value constructor doesn’t utilize it
anyhow. This makes rule a phantom type, likewise,
iAmPhantom here:
data SomeType iAmPhantom = NothingToSeeHere
Phantom types keep some extra information about other
types by means of free unused type parameters.
Phantom types have huge importance for the type-level
design, as we’ll see in further chapters.
What else can we do with type-level strings? We’re encoding
the name of an automaton, and it would be nice if we could
print it once we’re given some world. This conversion from
types to values in Haskell is simple, although it requires the
help of several exotic creatures from the depths of the
language. The zoo consists of:
▪ KnownSymbol type class from GHC.TypeLits;
▪ stringVal function that belongs to the KnownSymbol
type class.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 42
The following function does the conversion once provided with a
CellWorld value:
import GHC.TypeLits (Symbol, KnownSymbol, symbolVal)
automatonWorldName
:: KnownSymbol rule #A
=> CellWorld rule #B
-> String
automatonWorldName world = symbolVal world
#A Constraint requires `rule` to be of the Symbol kind
#B Denoting `rule` as Symbol is optional
Specifying the Symbol kind is optional because it is present in
the definition of KnownSymbol and CellWorld. This is,
however, allowed:
automatonWorldName
:: KnownSymbol (rule :: Symbol)
=> CellWorld (rule :: Symbol)
-> String
automatonWorldName world = symbolVal world
The symbolVal function sees no interest in the value itself, but
it needs the actual string type carried with the world variable.
With this world variable, we’re proxying the needed type to
symbolVal, so it could know what type-level string we want to
convert:
symbolVal :: KnownSymbol n => proxy n -> String
The automatonWorlName function stares at the CellWorld
type and sees that rule has the Symbol kind, so it’s fully
eligible for the KnownSymbol type class, and, therefore, for
symbolVal.
Usage is simple as long as we have a world, for example, Game
of Life:
type GoLRule = "Game of Life"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 43
type GoL = CellWorld GoLRule
gameOfLifeEmptyWorld :: GoL
gameOfLifeEmptyWorld = CW Map.empty
main :: IO ()
main = print (automatonWorldName gameOfLifeEmptyWorld)
> "Game of Life"
Haskell also supports type-level natural numbers (from 0 to
infinity). We could easily replace type-level strings with them
and achieve a similar effect.
import GHC.TypeLits (Nat)
newtype IndexedCellWorld (index :: Nat) = ICW Board
There is the Nat kind, the KnownNat type class, and the
function that converts type-level numbers into value-level
integers:
import GHC.TypeLits (Nat, KnownNat, natVal)
import Data.Proxy (Proxy (..))
type FourtyTwo = (42 :: Nat)
> natVal (Proxy :: Proxy FourtyTwo)
> 42
Here, we only have the FourtyTwo type and nothing else. In
the lack of a real value of this type, we’re constructing Proxy
from Data.Proxy that will help natVal to understand what we
mean. symbolVal supports this trick as well:
type GameOfLifeRule = ("Game of Life" :: Symbol)
> symbolVal (Proxy :: Proxy GameOfLifeRule)
> "Game of Life"
Besides strings and numbers, there are type-level
characters, type-level boolean values, type-level algebraic
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 44
data types, and so on. We’ll certainly need all this stuff in
our journey, piece-by-piece, step-by-step.
Thus, I invite you to explore this huge world of Pragmatic
Type-Level Design!
1.3 Summary
▪ Regular code is usually a mix of value-level and type-level
solutions.
▪ Type-level code has an apriori higher complexity than
value-level code. This can be wrong for specific cases.
▪ Type-level programming should be properly justified and
should introduce new meanings into the code.
▪ Design principles help to measure the complexity of the
solutions and avoid bad technical decisions.
▪ Typed Forms diagrams are helpful in designing with types.
▪ Type-safe code is code that has a good type representation
that is difficult to use wrongly.
▪ Newtype wrappers are useful to make the code more
type-safe.
▪ Type-level programming starts with understanding
generics, type classes, and type-level literals on the basic
level.
▪ Haskell allows for type-level literals with the DataKinds
extension enabled.
▪ Converting a compile-time type-level literal to a runtime
value is simple.
▪ There are type-level strings (Symbol), type-level natural
numbers (Nat), and other type-level kinds of types.
▪ Converting a runtime value to a type is very difficult and
requires a more powerful type system than Haskell has.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 45
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 46
Chapter 2
Use case: simple extensibility
This chapter covers
▪ What is extensibility
▪ Using type classes for a simple mechanism of extensibility
▪ What is the heterogeneous collections problem, and how to
solve it
Programming offers many joys, temptations, and luring
possibilities. Programming with types elevates that all to the
highest level. Developers love writing code in the name of
flexibility, genericity, and expressivity, and they don’t often
consider long-term consequences because they would be an
annoying distraction, a stone on the path to heaven.
Pragmatism rejects false and blind intentions and dictates to
justify decisions from a rational point of view. Overly flexible,
expressible, and extensible systems are often heavy,
unnecessarily complex, and hardly maintainable. Wrong
assumptions raise the risk of failure. Overengineering consumes
time and money without bringing any benefit.
Metaprogramming is a risky joy that can turn into suffering
after the initial enthusiasm is gone.
In this chapter, we’ll begin exploring use cases for type-level
design starting from extensibility. We’ll discuss what
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 47
extensibility is, what kinds of extensibility are there, and how to
decide between the options. We’ll encounter the need to clarify
the requirements for our domain, and will then develop a
simply extensible cellular automata application that will then be
our leading example through the whole book.
2.1 Extensibility
The idea of extensibility is always the same: adding new
behaviors to the system without redesigning it. These new
behaviors are often not known upfront, but thankfully, they can
fit into a general pattern that the system should support. We,
software architects, should be familiar with most of the
approaches to extensibility to make correct architectural
decisions. Some extensibility mechanisms operate on the value
level; others involve type-level programming or, more
commonly, operate on both.
Let’s discuss the topic of extensibility and establish its
vocabulary.
2.1.1 Extensibility mechanisms
Rich and sophisticated domains usually have many moving
parts that can be pledged for extensibility. The question is what
kind of extensibility best suits a specific case. This is always
situational; most likely, several approaches would work, and we
must choose.
First, there are “external” and “internal” extensibility
mechanisms.
▪ “External” extensibility. New behavior comes from outside
the project and can possibly be provided by a third party.
There is no need to modify the code or rebuild the project.
Two common examples are plugin systems and external
languages (external DSLs or scripting languages). Many
modern computer games officially support scripting with
Lua or allow injecting mods as plugins.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 48
▪ “Internal” extensibility happens inside the project and
requires adding more code. The extended application will
require rebuilding. Internal extensibility can be explicit and
implicit.
▪ Explicit internal extensibility. There is always a mechanism
to extend by providing more implementations. Examples
include Java and C# interfaces, C++ abstract classes,
Scala traits, Haskell’s Free monads, and type classes like
Foldable, Monoid, and Num.
▪ Implicit internal extensibility does not rely on any
abstraction. Instead, there is some switch in the project,
and adding more branches to it means the application now
supports more behaviors. This usually looks like a
switch-case feature or a list of supported items that should
be manually updated.
Second, explicit internal mechanisms can be separated into
interface-like abstractions and genericity-like abstractions. For
both, an abstraction can always be declared and provided with
one or many implementations. The difference is whether
substituting the implementations happens in runtime or compile
time. Figure 2.1 highlights the two kinds of abstractions.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 49
Figure 2.1 Genericity-like and interface-like abstractions
▪ Interface-like abstractions. These abstractions (interfaces)
describe the common behavior of similar domain notions
and allow the client code to treat each of them uniformly.
The client code doesn’t have to know about the internals of
the implementations and can only rely on the interface.
Implementations can be transparently substituted in
runtime for the client code. An interface can be a language
feature like interfaces in Java and C# or anything else that
does information hiding and encapsulation. Examples: Java
and C# interfaces, Haskell’s Free monads, Service Handle
pattern, first-class modules (Python, OCaml), and usual
first-class functions.
▪ Genericity-like abstractions. These abstractions handle the
essence of a domain notion with generic type-level
declarations. Providing an implementation means
specifying the generic type with a specific one at design
time and compile time. Examples: generics, type classes,
templates in C++, Haskell’s Foldable, Traversable, Monoid,
Semigroup, and so on.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 50
We’ll sometimes use “interface” in a general meaning (some
abstract face of the internal world), and sometimes as a specific
engineering mechanism. In this sense, genericity-like
abstractions may eventually be called interfaces.
2.1.2 Extensibility requirement
Previously, we stated that our application should support Game
of Life, but we actually noticed other Life-like rules: Seeds,
Replicator, Diamoeba. If we extend the requirement to support
a wider set of automata, we might soon get into trouble
because the variety of automata is only limited by our
imagination. There are five major degrees of freedom in this
deep domain:
▪ States configuration. Game of Life is a two-state
automaton with simple states: alive and dead (on and off,
0 and 1). However, the number of states and their
structure is only limited by our imagination. It can be just
a numerical state: 0, 1, 2, …, or can be something more
complicated, for example, weighted states that resemble
neural networks.
▪ Board geometry. The simplest board represents an
unlimited field of cells, but nothing can stop us from
having multi-dimensional boards of any shape, for
example, torus boards curved like ouroboros.
▪ Grid geometry. Cells are usually understood as
space-filling parquets: squares, triangles, and hexagons;
this is what the most popular automata look like. Also,
automata can be based on graphs of your taste.
▪ Rules and neighborhood. Even the simple 8-neighborhood
two-state square grid Game of Life automaton reveals the
abyss of possibilities, so imagine how many abysses of
abysses are hidden in the void when you’re allowed to
redefine what neighborhood is.
▪ Continuous or discrete. Who said that cellular automata
must utilize a discrete grid? Free your mind! Why not have
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 51
a continuous, floating point-based automaton and enjoy a
beautiful world of smooth forms and transformations? (See
figure 2.2)
Figure 2.2 SmoothLife - continuous cellular automaton
LINK SmoothLife implementation
https://sourceforge.net/projects/smoothlife/
Each of these enables a lot of possibilities, and all of them
combined represent an infinitely huge surface of complicated
automata, more or less interesting and intriguing. Even the
most powerful applications I’m aware of – Golly (figure 2.3) and
Mirek’s Cellebration (figure 2.4) – focus on a small subset of it
and do not address exotic configurations.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 52
Figure 2.3 Golly cellular automata simulator
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 53
Figure 2.4 Mirek’s Cellebration cellular automata simulator
LINK Golly
http://golly.sourceforge.net/
LINK Mirek’s Cellebration
http://psoup.math.wisc.edu/mcell/
Our application should have similar possibilities. It should allow
the user to investigate various automata, load, edit, and run
the worlds, store the intermediate results, and even create
custom rules. These are the must-have features, but I think of
one more that will distinguish our application from others. Let’s
say it should have an intellectual module that analyzes the
acting worlds and searches for “interesting” patterns. With this
feature, we might load many different rules and worlds and put
the program into an autonomous mode so we could return later
and see what was found. I believe this will be very helpful in
exploring what is yet hidden out there.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 54
We should also fixate on the limitations and the main degrees
of freedom because otherwise, we’ll be fighting a practically
impossible task. Let’s peek at this:
▪ 2D grid of squares
▪ arbitrary cell states
▪ arbitrary neighborhood
▪ arbitrary rules
We’d need some GUI, at least an ASCII printer, or maybe, even
graphical output.
Assuming all this, we seem to have several extensibility points.
Let’s identify them.
2.1.3 Extension points
Extension points are specific places in the code where
extensibility has been planned. We identify them and see what
abstraction is needed here. In the previous chapter, we
declared one, which was the Automaton type class. Its
specifications may represent various 2D 2-state cellular
automata:
Listing 2.1 Automaton extension interface
class Automaton a where #A
step :: a -> a
wrap :: Board -> a
unwrap :: a -> Board
newtype GoL = GoL Board
instance Automaton GoL where #B
step = golStep
wrap board = GoL board
unwrap (GoL board) = board
#A Interface
#B Implementation
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 55
This is a genericity-like abstraction by several criteria:
▪ type classes represent a compile-type polymorphism;
▪ implementing a type class means providing a type that
specifies the type class at compile time;
▪ there is no notion of a lifetime for implementations
(specifications): they will exist as long as the program
runs.
The type class abstraction also has some characteristics of an
interface-like abstraction:
▪ expresses some domain behavior;
▪ here, used as an encapsulation mechanism.
This means that the type class abstraction stands somewhere in
the middle between genericity-like and interface-like ones and
often is used as both. From my experience, I can tell that I
normally would not use type classes. I’d rather go with
interface-like abstractions: Free monads, Service Handle, or
maybe even bare functions. This would however lead us into
the field of functional design that I’ve discussed in the FDaA
book. As we have a different metagoal in mind, namely
type-level programming, we’ll deliberately prefer genericity-like
abstractions for our extension points.
Choosing between genericity-like and interface-like abstractions
is, therefore, a bifurcation point between type-level design and
functional declarative design.
Our application should have a renderer: either ASCII or a
graphical one. We can support both simultaneously by saying
that it’s again an extension point. Figure 2.5 shows a kind of
use case diagram from UML (Unified Modeling Language). It
tells us that there is a 2D cellular automaton simulator, 2
possible extension points, and 4 possible extensions.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 56
Figure 2.5 Extensible 2D cellular automata simulator
This diagram tries to be descriptive and avoids stating any
technical aspects of extensibility. We don’t really know what
those extension points will be. A Java developer may interpret
the diagram in typical Java terms: OOP interfaces, inheritance,
and subtyping. A C++ developer might want to think in terms
of abstract classes from the one side, and templates from the
other side. A modern C++ developer would also consider
concepts – a mechanism that highly resembles type classes.
Rust and Scala developers enjoy having traits. A Haskell
developer may hear the call of Free monads. What would we
choose?
▪ Extension point #1: genericity-like abstractions.
▪ Extension point #2: interface-like abstractions.
It seems the “extensibility point” has a broader meaning
because it’s not only an attribute of software engineering. You
may freely talk about extensible points in Lego. For example,
there is a standardized hole for technic pins, and it can accept
pins with friction, pins without friction, and axles. In electrical
engineering, interfaces of devices allow us to interchange parts
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 57
when something has broken (a lamp) or needs to be improved
(a GPU video card). We can easily find the notion of interfaces
and extension points in other engineering fields, too. This
concept is truly universal.
2.2 Basically extensible application
In this section, we’ll initiate the topic of type class extensibility
and discuss the subtle properties such a design would have.
We’ll see that a genericity-like type class mechanism can
somewhat mimic interfaces. Consider Chapter 5 for more
discussions on this topic.
By the end of the section, we’ll create everything needed for a
command-line application that could run the cellular worlds of
the supported rules.
Listing 2.2 Cellular automata application
Welcome to the world of cellular automata!
Type a command:
rules
Supported rules ([code] name):
[seeds] Seeds
[repl] Replicator
[gol] Game of Life
You can check out the full code of the application in the book's
repository.
2.2.1 Type class interface
Let’s recap what we did in the last chapter. We designed the
Automaton type class but found it violated several design
principles.
class Automaton a where
step :: a -> a
wrap :: Board -> a
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 58
unwrap :: a -> Board
It has multiple responsibilities, which makes it less coherent
and less logical. Then, seeking a better design, we’ve revisited
how we describe cellular worlds. We lifted the Board structure
from the specific automata to the interface part by introducing
a common type for all of them:
-- module Automaton:
newtype CellWorld (rule :: Symbol) = CW Board
This, however, broke the type class. We need to rework it to
support the new world structure.
The redesign starts with rethinking the wrap and unwrap
methods that make the interface inconsistent. Removing them
from the type class means ruining the saving and loading
functions which were the main clients of the methods:
saveToFile :: Automaton ca => FilePath -> ca -> IO ()
saveToFile file world = saveBoardToFile file (unwrap world)
There are several ways to save them; the worst would be
promoting them to the type class. It would require no wrapping
then but would make the interface even more inconsistent:
class Automaton a where
step :: a -> a
save :: FilePath -> a -> IO ()
load :: FilePath -> IO a
On the good side, the instances can have their own saving and
loading logic if that matters. For now, it doesn’t, as I see it. All
the automata will keep their identically structured worlds
similarly. Also, the inconsistency may eventually start growing
like a black hole, meaning, there will be no way back. One day,
for example, we may decide to incorporate database
possibilities:
class Automaton a where
step :: a -> a
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 59
save :: FilePath -> a -> IO ()
load :: FilePath -> IO a
saveToDatabase :: DatabaseConnection -> Key -> a -> IO ()
loadFromDatabase :: DatabaseConnection -> Key -> IO a
Look how many more bits to maintain have been involved: a
key to store and load and a connection to a database. The type
class becomes an extremely heavy object with much
information packed into it. High coupling, low cohesion and
violation of the Interface Segregation principle (ISP) are the
three sins this interface has.
Let’s do the following: drop the methods that don’t relate to the
domain and teach the new Automaton type class to work with
the CellWorld type. See listing 2.3:
Listing 2.3 Advanced interface for automata
import GHC.TypeLits ( KnownSymbol, Symbol, symbolVal )
import Data.Proxy ( Proxy(..) )
type RuleCode = String
class KnownSymbol rule #4
=> Automaton (rule :: Symbol) where #1
step :: CellWorld rule -> CellWorld rule #2
code :: Proxy rule -> RuleCode #6
name :: Proxy rule -> String #3
name _ = symbolVal (Proxy :: Proxy rule) #5
This type class has the following:
1. The type parameter rule must be a type-level string
(Symbol).
2. Method step that transforms the world specific to a concrete
automaton defined by CellWorld rule.
3. Method name that allows the instances to provide the name
for this particular automaton. It doesn’t require a value of
CellWorld, only the type of the rule.
4. Stock type class KnownSymbol is summoned to provide
additional functionality over the type-level string rule.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 60
5. Default implementation of the name method.
6. A short code that would identify the rule. The name can be
any, but the code should be short and unique.
Instantiating the Automaton type class now becomes pretty
easy (don’t mind the new Haskell extensions; they’ll be
explained later):
{-# LANGUAGE DataKinds #-} #A
{-# LANGUAGE FlexibleInstances #-}
type GoLRule = "Game of Life" #B
type GoL = CellWorld GoLRule
instance Automaton GoLRule where #C
step = golStep
code _ = "gol"
#A Some extra Haskell extensions are needed
#B DataKinds here
#C FlexibleInstances here
The corresponding diagram looks like this now:
Figure 2.6 Updated Automaton type class
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 61
NOTE On additional extensions. For some reason, defining
an instance for a type synonym was unavailable in the
standard Haskell. The FlexibleInstances fixes this
problem.
{-# LANGUAGE FlexibleInstances #-}
data NoNeedForFlexibleInstances = Test -- ADT
type NeedForFlexibleInstances = Int -- type synonym
class MyClass where
instance MyClass NoNeedForFlexibleInstances where
instance MyClass NeedForFlexibleInstances where #A
#A FlexibleInstances
Remember there was a generic automatonWorldName
function for getting the name of an automaton?
automatonWorldName
:: KnownSymbol rule => CellWorld rule -> String
We can’t use it without a world value. This unfortunate
limitation is fixed with the new name method that only needs to
know about a cellular type. We can say the function is “static”
by the analogy with static methods in C# that are related to
some (object-oriented) class but do not require an object of
that class to be materialized. Surely, name wants a Proxy
value, but that’s easily constructible once we have the rule
type variable. An additional plus of name is that the instances
may decide how to form the string. Be it a direct translation of
the type-level title or an extended description of the
automaton, this is up to the implementations, for example:
instance Automaton GoLRule where
step = golStep
code _ = "gol"
name _ = "I’m Game of Life, thank you John Conway!"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 62
Generic saving-loading functions are also possible because they
can access CellWorld’s internal structure with only the
knowledge the type class provides:
saveToFile
:: Automaton rule => FilePath -> CellWorld rule -> IO ()
saveToFile file (CW board) = saveBoardToFile file board
It seems we’ve reached our first design accomplishment. We
made the interface to the subsystem clean and consistent
regarding functionality, but it was quite a burden in terms of
type-level bits. To be precise, this issue comes with the idea of
using type classes as an interface-like mechanism, which they
aren’t. A truly interface-like mechanism (for example, Free
monads) would be much cleaner and robust, but it was our
intent to keep as close to the type level as possible. We’ll return
to this talk in Chapter 5.
I’m fine tolerating this wordiness and syntactic chaos as long as
there are other benefits. However, the further development of
the application reveals a more serious issue.
2.2.2 Heterogenous storage problem
According to the requirements, we want to load multiple worlds
from files, then step them once or many times, and finally print
the actual states of the worlds.
There is no special format for the world files. It’s just plain text
with some board configuration where x denotes an alive cell:
-- gol.txt:
.......
.......
...x...
....x..
..xxx..
.......
.......
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 63
This pattern behaves interestingly in Game of Life (it’s a glider
moving to the right-bottom direction), but for other rules, it
means something else and may reveal no worthy behavior.
We’d better say it’s Game of Life when we load it. Loading it as
Seeds or Replicator is also permitted if we want to experiment.
This way or another, in the current data design, we must
specify the rule for the acting world because it’s not stored in
the file itself.
The rules should be available for picking somehow. Why not ask
for the rule code from the user and then decide? Like this:
putStrLn "Specify the rule code for loading:"
ruleCode <- getLine
case ruleCode of
"gol" -> loadGoLWorld
"seeds" -> loadSeedsWorld
"repl" -> loadReplicatorWorld
_ -> putStrLn "Unsupported rule."
This sloppy solution will work but will render us immature
engineers. Be sure that such switch constructions will occur in
numbers all across the project. In fact, this approach violates
the Open-Closed (OCP) principle. The principle states that the
code should be open for extensibility but closed for
modification. We violated it because we will need to modify
many places every time a new rule comes, and we can easily
forget a switch buried deep within the bowels of the project.
This approach doesn’t scale, and it’s not an extensibility.
We would like to put the supported rules into a single structure,
let’s say, a list or a dictionary, and then consult with it to verify
if the rule is supported and what loading/stepping functions it
has. This will be the only place in the project to modify, apart
from adding more user-defined cellular types and type class
instances. This is a common idea, I’d say, a design pattern if we
need to provide a variety of entities to choose from and yet
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 64
want to avoid a lot of switches. In OOP, Registry and Factory
will be the corresponding design patterns.
Our case has a peculiarity: there are no runtime rule entities,
only user-defined types available at design time and compile
time. Does it mean we need a type-level list? Well, that’s
possible, but not in this chapter – we’ll touch on this advanced
technique later. For now, let’s form an associative value-level
list that maps rule codes to the rules represented by Proxy
placeholders:
Listing 2.5 Malfunctioning heterogenous rules list
rules1 :: [(RuleCode, Proxy rule)]
rules1 =
[ ("gol", Proxy :: Proxy GoL)
, ("seeds", Proxy :: Proxy Seeds)
, ("repl", Proxy :: Proxy Replicator)
]
Unfortunately, this won’t compile. It fails because all three
proxy values have their own types, so they can’t be listed
together. Haskell’s stock collections (lists, maps, sets) are
homogenous, while our proxies are all of distinct types. Several
naive Haskell tricks fail, too:
Listing 2.6 More malfunctioning heterogenous rule lists
rules2
:: Automaton rule #A
=> [(RuleCode, Proxy rule)]
rules2 =
[ ("gol", Proxy :: Proxy GoL)
, ("seeds", Proxy :: Proxy Seeds)
]
rules3
:: [(RuleCode, Proxy rule)]
rules3 =
("gol", Proxy :: Proxy GoL) #C
: ("seeds", Proxy :: Proxy Seeds)
: []
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 65
#A Explicit requirement for Automaton
#B Explicit requirement for Automaton
#C Item-by-item construction of a list
Here, we’ve demanded the Automaton requirement for the
rule type variable. With this constraint, we know something
about all possible rules, but nevertheless, this won’t compile.
The stock collections don’t want a generic type to be specified
in any form.
The same will happen with the storage of the acting worlds. We
can’t put them into a homogenous dictionary:
golWorld :: CellWorld GoL #A
seedsWorld :: CellWorld Seeds
replicatorWorld :: CellWorld Replicator
worlds1 :: Map String (CellWorld rule)
worlds1 = Map.fromList
[ ("Game of Life", golWorld) #B
, ("Seeds", seedsWorld)
, ("Replicator", replicatorWorld)
]
#A Pre-defined worlds
#B Won't compile: worlds type mismatch
This seems a rather misfortune limitation that may ruin the idea
of type-level extensibility that started from our desire to stay
with type classes. Are we in trouble now?
Heterogeneous collections in Haskell exist, but using them is
quite painful and may still be inappropriate due to their rigid
interfaces and a lot of type-level magic. Luckily for us, two
solutions solve the problem and allow us to use the stock
homogenous collections. Each solution is about providing a
specific adapter so that the collections cannot tell the
difference. Let’s call these solutions “valuefied” and “existential”
correspondingly.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 66
2.2.3 Valuefication and existentification
Why two solutions? This is another bifurcation point between
the value-level path and the type-level path, and it will be
beneficial to investigate both.
▪ Valuefication: making a value-level functional wrapper
from the type class machinery, erasing the user-defined
types, and moving all the logic into functions over common
types.
▪ Existentification: making an existential algebraic data type
that encapsulates the user-defined types and type class
instances so that the distinction is not seen from the
outside.
Figure 2.7 brings these two into the diagram of levels: type /
type+value / value. Notice that we don’t know yet how to lift
the middle solution to the purely typed level.
Figure 2.7 Conversion between solutions
NOTE Valuefication is possible in almost every statically
typed language. Existentification can be challenging but
possible: if not directly, it can be done with the help of
the Any type and the Typed-Untyped design pattern. See
Part IV: Rosetta Stone for more info.
Let’s do valuefication first.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 67
2.2.4 Valuefied storage
We’re going to craft a homogenous list of rules represented by
the following RuleImpl type that has everything needed to
work with an automaton:
data RuleImpl = RI
{ ruleName :: String
, ruleCode :: RuleCode
, ruleLoad :: FilePath -> IO Board
, ruleStep :: Board -> Board
}
This ADT is very simple, and we could achieve this if we have
chosen the path of Functional Declarative Design from the start.
Given the type, it’s now possible to wrap every automaton type
and trick the list:
supportedRules :: [(RuleCode, RuleImpl)]
supportedRules = map (\ri -> (ruleCode ri, ri))
[ toRuleImpl (Proxy :: Proxy SeedsRule)
, toRuleImpl (Proxy :: Proxy ReplicatorRule)
, toRuleImpl (Proxy :: Proxy GoLRule)
]
The function toRuleImpl that performs the valuefication is
presented in listing 2.7:
Listing 2.7 Valuefication of Automaton instances
toRuleImpl :: Automaton rule => Proxy rule -> RuleImpl
toRuleImpl proxy = RI
(name proxy) #A
(code proxy)
loadBoardFromFile
(valuefyStep proxy) #B
valuefyStep
:: forall rule #C
. Automaton rule #D
=> Proxy rule
-> (Board -> Board)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 68
valuefyStep _ board1 = let
cw :: CellWorld rule = step (CW board1) #E
CW board2 = cw
in board2
#A Valuefication of `name` and `code`
#B Valuefication of a particular `step` function
#C Makes `rule` accessible from the body
#D Requires Automaton instance for `rule` type variable
#E Typed `step` function usage
Notice that we pass a proxy and then interact with the
Automaton type class through it. The proxy is aware of the
user-defined type, and this information only circulates inside
the toRuleImpl function without slipping into the list. The list
sees a fully evaluated function result of type RuleImpl.
Valuefication can be done to any type class, although it might
require some complicated conversions. In this case, the most
tricky one was the step function. In valuefyStep, we first
construct an intermediate CellWorld value, feed the existing
and typed step function, and then unpack the result. The
function secretly hides what it does to the CellWorld types
and only reveals the board-aware interface.
Storing acting worlds is essentially storing a board value for
each. For convenience, we will assign ordinal numbers to the
worlds as they are loaded and put them into a homogenous
dictionary storage:
type Generation = Int
data WorldInstance = WI
{ ruleImpl :: RuleImpl #A
, worldGen :: Generation #B
, worldBoard :: Board #C
}
type WorldIndex = Int
type Worlds = Map WorldIndex WorldInstance
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 69
#A Rule implementation for this world
#B Generation of the world
#C The world itself
It doesn’t have any type-level parameters, and it utilizes the
non-parametrized RuleImpl adapter. Both types have an
obvious usage, so I’ll omit them here. Consider the full code in
the book's repository.
2.2.5 Existential storage
With existentification, the information about distinct types gets
encapsulated not within a function but within a generalized
algebraic data type. Regular Haskell’s algebraic data types are
too weak for this, so we have to enable one more extension and
use a slightly different syntax, the GADTs syntax:
{-# LANGUAGE GADTs #-}
data RuleImpl where #A
RI #B
:: Automaton rule #C
=> Proxy rule #D
-> RuleImpl #E
#A Generalized ADT
#B Value constructor
#C Internally defined constraint for `rule`
#D Field that contains a proxy
#E Actual type of the RI constructor
NOTE We don’t use all the properties of generalized ADTs
here. Our GADT is very primitive. It’s not parametrized,
so the only value constructor RI has the only possible
type, RuleImpl. In a more developed GADT, value
constructors are allowed to have specialized types. We’ll
see more usages of this feature in chapter 7 Use case:
type-level object-oriented programming.
The RuleImpl type is now an existential wrapper that is the
only one that knows about its contents. It contains only one
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 70
field of type Proxy rule, and encloses a designation that this
rule should have an Automaton instance. The instance will be
provided implicitly, and the outside code will never know that
the RI value constructor has something tricky inside.
Now we compose the list of rules by just enclosing proxies into
RuleImpl values:
Listing 2.8 Existentification of Automaton instances
supportedRules :: [(RuleCode, RuleImpl)]
supportedRules = map (\ri -> (getCode ri, ri))
[ RI (Proxy :: Proxy SeedsRule) #A
, RI (Proxy :: Proxy ReplicatorRule)
, RI (Proxy :: Proxy GoLRule)
]
getCode :: RuleImpl -> RuleCode
getCode (RI proxy) = code proxy #1
#A Existential wrappers
Importantly, pattern-matching over an existential value
constructor discloses the information to the subsequent code.
This is what getCode does at #1, but it does it implicitly. We
can reveal invisible existential information by passing the proxy
into another function:
getCode :: RuleImpl -> RuleCode
getCode (RI proxy) = getCode' proxy #A
getCode' :: Automaton rule => Proxy rule -> RuleCode
getCode' proxy = code proxy
#A Disclosing the existential to the subsequent call
The getCode' function expects a proxy argument of the type
(Automaton rule => Proxy rule) – exactly the same as
the wrapper hides. Now the function sees it all: Automaton
constraint, and the actual type frozen there (SeedsRule,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 71
GoLRule, ReplicatorRule). The function therefore can freely
interact with the instance, for example, it can ask for a code.
Haskell’s type system prevents the information from leaking
beyond the pattern-matching point. Trying to expose rule to
the outside world will fail. Consider this:
exposeProxy :: Automaton rule2 => RuleImpl -> Proxy rule2
exposeProxy (RI proxy) = proxy #A
#A Implicit rule1 type parameter here
This snippet won’t compile. The proxy value is prohibited from
leaking and thus cannot be returned as a result. The compiler
will say that the proxy value is parametrized by the implicit
rule1 type parameter. At the same time, the extractProxy
has another rule2 type parameter that has nothing to do with
the first.
This circumstance pledges a difference in how we use
RuleImpl here from how we did with the valuefied solution.
Previously, we were keeping the step function inside RuleImpl.
This time, we can tie the Automaton constraint right to the
WorldInstance value and make it existential. Look:
data WorldInstance where
WI :: Automaton rule
=> Generation
-> CellWorld rule
-> WorldInstance
We don’t need a proxy because CellWorld does the job of
keeping rule well. But we need a proxy of a rule to specify
what world we’re loading. Listing 2.9 shows this last piece of
the puzzle:
Listing 2.9 Loading a world
loadWorld :: RuleCode -> FilePath -> IO WorldInstance
loadWorld ruleCode path =
case lookup ruleCode supportedRules of
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 72
Nothing -> error "Unknown rule."
Just (RI proxy) -> loadWorld' proxy path #A
loadWorld'
:: forall rule #B
. Automaton rule #C
=> Proxy rule #D
-> FilePath
-> IO WorldInstance
loadWorld' _ path = do
world :: CellWorld rule <- loadFromFile path #E
pure (WI 0 world)
#A Unpacking
#B Makes `rule` accessible from the body
#C Requires Automaton instance for `rule`
#D Brings `rule` into the function
#E Uses the knowledge of `rule`
We obtain a RuleImpl value from the supportedRules list,
unpack the proxy, and then use it to construct a
WorldInstance value. Once we need to step the world, we
can unpack its CellWorld value into a subsequent function,
perform stepping, and wrap the result back into
WorldInstance. You might want to try this procedure, or you
might want to consult with the repository and see how it’s
done.
Let me summarize the approach in the following table; we’ll fill
the other two columns soon in this book:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 73
Figure 2.8 Extensibility approaches
The last moment I’d like to highlight is that valuefying an
existential is simple, but existentifying a value is near
impossible. Shall we update the diagram?
Figure 2.9 Conversion between solutions
NOTE Other languages may or may not support existentials
and generalized ADTs. For example, C++ has neither of
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 74
these, but it’s possible to simulate both. See Appendix B:
Existential Fight Club to compare implementations in
various languages.
In general, transitioning from the top to the lower levels is
always possible, but the reverse can pose significant challenges
and require more complex type systems. In particular, the idea
is to construct types dynamically in response to some runtime
value. If the value changes, so does a type of other value,
which means there is a dependency from the bottom value level
to the upper type level. These systems are called “dependent
types,” which are quite rare in programming languages. As of
the writing of this book, Haskell doesn’t have dependent types.
Only a few languages support it: Idris, Agda, Coq. We have
workarounds in Haskell known as “singleton types,” which are
an unergonomic limited version of dependent types. The full
potential of dependent types for software design remains
unexplored, although some exciting use cases have already
been found, such as parsing libraries. We may not have such
tasks, but maybe we need another book on this subject.
2.3 Summary
▪ Extensibility is one of the main use cases for type-level
programming.
▪ There are at least two kinds of extensibility: “external” and
“internal” ones.
▪ There are two kinds of abstractions: genericity-like
abstractions and interface-like abstractions.
▪ Genericity-like abstractions are mainly situated at
compile-time, and interface-like abstractions also address
runtime.
▪ Type classes are not interfaces in the purest, but they
possess some properties of interfaces.
▪ Type classes enable simple extensibility; adding more
functionality means adding independent user-defined types
and implementing the type class.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 75
▪ Using type classes as interfaces may pose challenges, such
as the need for heterogeneous collections.
▪ The need for heterogeneous collections is a common
problem in type-level design.
▪ Valuefication and existentification are the two solutions for
overcoming the problem of heterogeneous collections.
These two techniques wrap independent user-defined
types so that their values can be put into a homogenous
collection.
▪ Valuefication lowers the level from mixed (type level +
value level) to value level.
▪ Existentification works on the mixed level (type level +
value level).
▪ Both techniques can be implemented in other languages.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 76
Chapter 3
Use case: genericity and
customization
This chapter covers
▪ Techniques for generic type-level programming
▪ What is genericity and what is customization
▪ How to create a tiny type-level eDSL
In the early eras of cellular automata, it wasn’t even known if
Conway’s Game of Life could produce infinitely many new alive
cells, but it was widely believed that such an ability could reveal
much more complex behavior. All these hopes came true when
the Gosper’s gun was discovered. This pattern produces a
stream of gliders repeatedly and infinitely, so it was possible to
do such things as signaling, infinite cell production and delivery,
and wiring – right within a GoL world.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 77
Figure 3.1 Gosper’s glider gun
After that, many other structures were found, and the question
of how they interact became more important. The search for
interesting structures still continued, but the overall effort has
shifted from low-level cell juggling to a large-scale design that
involves entire patterns and groups of cells so that people could
solve more ambitious tasks.
Type-level design can be seen this way, too. We start with small
techniques of generic programming and then ascend to
high-level domain modeling of big and meaningful systems.
This is what I call an “emergent system”: given small pieces,
you can achieve much more by combining them and even
observing new properties that never existed in the separate
parts.
Up to this point, we've embarked on a brief journey into
Haskell's enigmatic type-level underground. There, we've
encountered various wonders, including:
▪ Type-level strings and numbers
▪ Parametrized ADTs
▪ Phantom types
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 78
However, the shadows conceal even more mysteries. We will dig
deeper to uncover additional tools:
▪ Empty ADTs
▪ Type-level booleans
▪ Type-level lists
▪ Type-level ADTs
By the end of the chapter, we’re going to achieve this:
type D = 0 -- Dead cell state
type A = 1 -- Alive cell state
-- Game of Life (B3/S23):
type Neighbors3 = 'NeighborsCount A '[3 ]
type Neighbors23 = 'NeighborsCount A '[2,3]
type B2S23Transitions =
'[ 'StateTransition D A Neighbors3 #A
, 'StateTransition A A Neighbors23 #B
, 'DefaultTransition D #C
]
#A "Born" rule
#B "Survive" rule
#C Default case
This is a Game of Life rule definition made fully as a type that
declares a state machine’s transition table with the
Born/Survive notation encoded. As you might guess, I can write
any Life-like rule with this notation, and not only. This is a tiny
type-level eDSL that, given generic tools, allows to customize of
various business-ready solutions.
3.1 Type-level genericity
In the context of type-level programming, "genericity" refers to
the capacity to write code that is abstract over types yet retains
type-specific behavior.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 79
This is how a GPT defines “genericity,” and I would say this
definition is quite unclear to me. What I call “genericity” is
when there is some generic operation that can be implemented
for user-defined types not known upfront. The types may
represent domain notions or can be bricks for building
type-level models. This way or another, the types should be
eventually turned into something runnable, hence the generic
operation that connects the two worlds.
In this section, such a generic operation will be the describe
function, the only role of which is to give us a string description
for whatever type it receives.
class Description a where
describe :: Proxy a -> String
We’ll be doing “type class-based types interpretation” a lot in
this book. With this technique, types can be examined,
interpreted, or evaluated, and they should embody certain
domain meanings.
3.1.1 Empty ADTs
A good type-level code embodies certain meanings in types.
The simplest way to demonstrate this would be empty ADTs.
They are empty because they don’t have value constructors, so
they are disabled for the value level. We can only have proxies:
data IAmEmptyADT -- No value constructor available
data IAmAnotherEmptyADT -- No value constructor available
emptyADTProxy :: Proxy IAmEmptyADT
emptyADTProxy = Proxy
anotherEmptyADTProxy :: Proxy IAmAnotherEmptyADT
anotherEmptyADTProxy = Proxy
Given a proxy, we can inspect the type and connect it to some
runtime operation using a type class, see listing 3.1:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 80
Listing 3.1 Inspection of a type
instance Description IAmEmptyADT where
describe _ = "IAmEmptyADT"
instance Description IAmAnotherEmptyADT where
describe _ = "IAmAnotherEmptyADT"
main = do
print (describe emptyADTProxy) #A
print (describe anotherEmptyADTProxy) #B
#A Outputs "IAmEmptyADT"
#B Outputs "IAmAnotherEmptyADT"
Empty ADTs are not useless. They can serve as type tags, for
example. Remember our shenanigans with type-level strings?
Why not do it with empty ADTs instead:
newtype CellWorld rule = CW Board #A
data GoLRule #B
type GoL = CellWorld GoLRule
#A The `rule` type variable is now of a common kind
#B Type tag
Previously, the rule type parameter was of the kind Symbol,
but our empty ADTs are not type-level strings so we just
removed this restriction. Let’s continue the discussion on kinds
in the next sections though.
More interesting use cases occur if we bless empty ADTs with
one or many type parameters and start nesting ADTs like
Russian dolls.
data Benoit
data Mandelbrot name
type Fractal = Mandelbrot (Mandelbrot (Mandelbrot Benoit))
main = print (describe (Proxy :: Proxy Fractal)) #A
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 81
#A Outputs: Mandelbrot Mandelbrot Mandelbrot Benoit
Figure 3.2 Fractal: recursive type
Interpreting the types should also follow the nesting pattern.
Notice that for the parameter of the Mandelbrot ADT, we
should require the Description instance as well:
instance Description Benoit where #A
describe _ = "Benoit"
instance
Description n => #B
Description (Mandelbrot n) where
describe _ = "Mandelbrot " <> describe (Proxy :: Proxy n)
#A Base of the recursive interpretation
#B Recursive case
In case we forget to provide the first instance, the compiler will
stop compiling and will notify us about that, so in some sense,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 82
this type-level recursion is by default safer than value-level
ones.
TIP Parametrization and empty ADTs have their own
implementations in other languages. See Part VI: Rosetta
Stone for more info.
3.1.2 Type applications
One small life improvement that we’ll be using from this
moment is called type applications in Haskell. It’s a way to
shorten type specifications when calling generic functions or
using parametrized types. The name resembles the usual
function parameter application because the idea is similar but
works on a different level.
In the previous code, we can shorten proxy descriptions. The
TypeApplications GHC extension should be enabled:
+ {-# LANGUAGE TypeApplications #-}
- main = print (describe (Proxy :: Proxy Fractal))
+ main = print (describe (Proxy @Fractal))
The Proxy type we know contains one type parameter:
data Proxy p = Proxy
When applied, @Fractal fills the first free parameter. But what
if the type is compound? Like these two, Person and User:
data Person (firstName :: Symbol) (lastName :: Symbol)
data User (login :: Symbol) person
The type application feature supports this too, we just need to
enclose the whole type in brackets:
main = print (describe (Proxy @(Person "Benoit" "Mandelbrot")))
Or we can, certainly, create type aliases:
type MandelbrotPerson = Person "Benoit" "Mandelbrot"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 83
type MandelbrotUser = User "mandel" MandelbrotPerson
main = print (describe (Proxy @MandelbrotUser))
It’s this simple. It’s this convenient. (I’ll secretly keep in mind
that there are cases when type applications make the code
more confusing than clear).
By the way, there are no instances of Description for these
types yet. Take a look and notice that generic type parameters
can also be specified with @:
instance
(KnownSymbol fn, KnownSymbol ln) =>
Description (Person fn ln) where
describe _ =
symbolVal (Proxy @fn) <> " " <> symbolVal (Proxy @ln)
instance
(KnownSymbol login, Description person) =>
Description (User login person) where
describe _ = symbolVal (Proxy @login)
<> " " <> describe (Proxy @person)
The instances decompose User and Person following their
nested structure. It’s this simple. It’s this powerful. (I'll
modestly keep silent that we’re just lucky to have simple ADTs
which is not often the case).
There are some circumstances when using type applications
with generic functions. Take a look: getUserDescription, a
single function that doesn’t follow from any type class, accepts
a login, a pin code, and a person, and composes a string
description manually:
Listing 3.2 Generic multiparameter function
getUserDescription
:: forall login pincode person fn ln #A
. ( KnownSymbol login #B
, KnownNat pincode #C
, KnownSymbol fn
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 84
, KnownSymbol ln
)
=> Proxy (User login (Person fn ln))
-> String
getUserDescription _
= "User: " <> symbolVal (Proxy @login)
<> ", pincode: " <> show (natVal (Proxy @pincode))
<> ", person: " <> symbolVal (Proxy @fn)
<> " " <> symbolVal (Proxy @ln)
#A `forall` specifies the order of type parameters
#B Login is a type-level string
#C Pin is a type-level integer
When invoked, generic functions expect the parameters in the
forall clause to be filled in the order the clause specifies
them. We can however skip some if they are decidable from
other entities. For instance, the login type parameter comes
with User, so we don’t really need to clarify it for the function.
We’re more interested in the pin code because it comes from
nowhere. We skip login with @_ and pass the pin:
main = print
(getUserDescription
@_ #A
@4321 #B
(Proxy @MandelbrotUser)) #C
#A Skipping the login type parameter
#B Passing a type-level integer pincode
#C Providing the actual argument of the function
I left the last type parameters (person, fn, and ln)
unmentioned because they are decidable from
MandelbrotUser.
NOTE As usual for the advanced Haskell stuff, some extra
extensions should be enabled for this code:
KindSignatures, DataKinds,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 85
AllowAmbiguousTypes. If one is missing, the compiler
will propose it. I’ll omit the explanations here.
By the way, we just created a simple type-level Person-User
eDSL. We did it with empty parametrized ADTs where phantoms
are like fields and the type is like a record. This makes sense.
Now two questions arise: 1) what else we can use for these
fields? 2) record is half of a type-level ADT, can we do it full?
The answers lie in another untamed wild of Haskell’s type
system. Let’s take a short look at the kind system before we’re
ready to work on the ADT-based type-level cellular automaton
eDSL.
3.1.3 Ordinary and specific kinds
Symbol and Nat are not the only stock kinds in Haskell. There
is also Char which has some obscure usage. I’ll omit it here,
but will show how to make a type-level ADT from effectively
any regular ADT out there. We’ll start with the Bool ADT that
has this form:
data Bool = True | False
Let’s add another “field” to User. It will be a flag indicating
whether the user has completed the verification or not:
{-# LANGUAGE DataKinds #-}
data User
(login :: Symbol) #A
(verified :: Bool) #B
person #C
#A “Field” of the Symbol kind (type-level string)
#B “Field” of the Bool kind (type-level bool)
#C “Field” of any regular kind
Being used like this, Bool becomes promoted one level up into
the world of kinds. Its value constructors are also promoted to
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 86
have the specific (not ordinary) Bool kind: ('True :: Bool)
and ('False :: Bool). See figure 3.3 for clarification:
Figure 3.3 Bool type and Bool kind
The preceding apostrophe distinguishes between values and
types and indicates it’s now promoted:
type MandelbrotUser = User "mandel" 'True MandelbrotPerson
(I find this notation a bit inconsistent. I’d expect the apostrophe
in the front of the Bool kind, but there isn’t, so we should rely
on the context in which the identifier is used.)
We should update the “description” interpretation mechanism
now, but there is a problem. The Description type class will
not work for the type-level Bool. The full definition gives a clue
as to why not:
class Description (a :: *) where
describe :: Proxy (a :: *) -> String
It expects a type of regular star (*) kind. For example,
instantiating it for (Bool :: *) is possible although sort of
meaningless:
instance Description Bool where
describe _ = "???"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 87
What to do with promoted types then? We can enroll one more
type class for “type-level pattern-matching” on 'True and
'False. The class will only accept these two, and neither of the
other kinds. It will have a two-argument method
describeBool that returns either this or that value depending
on the trueness of the instantiated type (see also figure 3.3):
Listing 3.3 Promoted type-level ADT inspection
class BoolDescription (a :: Bool) where #A
describeBool
:: Proxy (a :: Bool)
-> String -- on True
-> String -- on False
-> String
instance BoolDescription 'True where #B
describeBool _ onTrue _ = onTrue
instance BoolDescription 'False where #C
describeBool _ _ onFalse = onFalse
#A Inspecting all types of the kind Bool
#B Pattern-matching on 'True
#C Pattern-matching on 'False
Figure 3.4 BoolDescription type class
The new verified field can be finally described:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 88
getUserDescription
:: BoolDescription verified
=> Proxy (User login verified person)
-> String
getUserDescription _ = "verified: "
<> describeBool (Proxy @verified) "verified" "not verified"
You might feel uncomfortable with the potential explosion of
various type classes for various ADTs. Just imagine:
BoolDescription, EitherDescription,
MaybeDescription, and what’s not. This somewhat violates
the KISS and DRY principles and probably brings an
unnecessary burden on the code. The problem is that the star
kind and the specific kinds do not match. So what would we do?
I must admit: the very notion of a kind we were gravitating
around is obsolete and reworked in Haskell. I also should
remind you that when I say “Haskell,” I mean “the GHC
compiler,” the only Haskell implementation available out there.
The Haskell language has stuck with its last 2010 standard and
the rest of the development was done as extensions to the GHC
compiler with no success standardizing anything of it.
Let’s talk about the kind system briefly. Its complete rehaul
comes with the PolyKinds extension. When enabled, it
substitutes kinds by types of types with not much distinction
between the two. It brings a lot of things under the hood, and
the kinds magically become types.
In practice, this means that we’re now allowed to instantiate
the Description type class for both regular types and
promoted ones, but we should remove the star from it. It’s no
longer of the “star” kind, it is any other possible kind you can
imagine. Here:
{-# LANGUAGE PolyKinds #-}
class Description (a :: any) where #A
describe :: Proxy a -> String
instance Description 'True where
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 89
describe _ = "verified"
instance Description 'False where
describe _ = "not verified"
#A Any kind is allowed except the star
TIP The any type variable can be omitted or can be used as
a type variable in constraints. Quite often you can see it
named differently in the Haskell codebases. The most often
name for it was k which stands for “kind”, obviously.
With PolyKinds, the stars have fallen. Certainly, more
significant changes are brought by this extension, and more
other nuances can be told. Right now, they are not particularly
interesting to us, because we want to focus on the design of
useful things, not on the intricacies of the type system
(pragmatism, remember?), but the old “kind” terminology is
kind enough when doing type-level design, so we’ll stick to it.
3.1.4 Custom type-level ADTs and kinds
Notice that User and Person data types aren’t actually related.
Their definitions are separate although the person type
parameter eventually accepts Person. On a closer look, it also
accepts anything that has the star kind. This enables
meaningless specifications, for example:
type InvalidUser = User "invalid" 'False Int
We might want to strengthen the two types, remove ambiguity,
and thus follow the design principle Make Invalid States
Unrepresentable.
PRINCIPLE Make Invalid States Unrepresentable is a
design guideline that suggests modeling the types and data
structures in such a way that it's impossible, or at least
difficult, to represent a state that is not valid within the
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 90
particular domain. Haskell’s type system offers many tools
for leveraging a safer code with this principle.
The naive attempt to assign a specific kind to the person type
variable succeeds itself (listing 3.4):
Listing 3.4 Updated User type
data Person (firstName :: Symbol) (lastName :: Symbol)
data User
(login :: Symbol)
(verified :: Bool)
(person :: Person fn ln)
type HausdorffPerson = Person "Felix" "Hausdorff"
but fails when we’re trying to create a user:
type HausdorffUser = User "haus" 'True HausdorffPerson
> Compilation error: Expected kind ‘Person fn0 ln0’, but
‘HausdorffPerson’ has kind ‘*’
Indeed, we have the Person type and its same-name kind
Person promoted implicitly by DataKinds. So why do we have
this error then? Figure 3.4 reveals all these behind-the-scenes
moves and helps us to realize that HausdorffPerson has the
star kind yet, which is not what the User type expected:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 91
Figure 3.5 Person and User empty ADTs promotion
Short story long; we’re going to fix this by doing the types
slightly differently. Let’s drop empty ADTs and write regular ones.
For better clarity, I’ll use another naming scheme here. ADT type
will have the “Type” postfix while value constructors will remain
unprefixed. See listing 3.5:
Listing 3.5 Person and User regular ADTs
{-# LANGUAGE DataKinds #-}
data PersonType = Person
{ firstName :: Symbol
, lastName :: Symbol
}
data UserType = User
{ login :: Symbol
, verified :: Bool
, person :: PersonType #A
}
#A Regular ADT nesting
Now we have these types related in a proper way through the
person field. Also, we seem to keep Symbol there, like it was a
regular type, but it really isn’t. We can’t produce values of
these ADTs because there is no anything value-level that could
be Symbol.
hausdorffPerson :: PersonType
hausdorffPerson = Person ??? ??? #A
#A Nothing to put here
If so, what does this design buy for us? Well, something.
Once the DataKinds extension is enabled, an implicit promotion
happens for every ordinary type a module contains. If there is
an ADT in the module, its type-level version will occur there, in the
nothingness between the lines. Not only that; very interesting
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 92
transformations will happen to the fields when being lifted to the
type level. What was a type, becomes a kind. What was a field,
becomes a type parameter. This is what you’ll get (pseudocode):
type ('Person fn ln) :: kind PersonType
type fn :: kind Symbol
type ln :: kind Symbol
type ('User log val per) :: kind UserType
type log :: kind Symbol
type val :: kind Bool
type per :: kind PersonType
See figures 3.6 and 3.7 for better clarity:
Figure 3.6 PersonType type-level ADT
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 93
Figure 3.7 UserType type-level ADT
NOTE The names of fields do not become the names of
type parameters. I named them identically, but I could
choose the names arbitrarily.
NOTE Notice the apostrophe that precedes the implicit
promoted type 'Person.
Given that, we are able to typify our domains properly with
respect to the Make Invalid States Unrepresentable principle:
type HausdorffPerson = 'Person "Felix" "Hausdorff"
type HausdorffUser = 'User "haus" 'True HausdorffPerson
Interpreting these types and connecting them to value-level
constructs works exactly the same as for empty ADTs. In fact,
we only need a small update of the instances, literally just
adding the apostrophe is enough:
instance
(KnownSymbol fn, KnownSymbol ln) =>
- Description (Person fn ln) where
+ Description ('Person fn ln) where
describe _ =
symbolVal (Proxy @fn) <> " " <> symbolVal (Proxy @ln)
This might first look like an easy win, and it really is in some
sense. However, we’ll see that type-level design made this way
is not necessarily easy and sometimes feels like a fight with the
compiler. All of a sudden, some code breaks, and it’s difficult to
understand why. That’s not a pleasant experience, but it’s our
reality. At least we should be happy that we don’t do type-level
programming with templates in C++!
TIP Implementing type-level ADTs in C++ is also possible.
See Part IV: Rosetta Stone for more info.
Last but not least type-level generic tool we will study in this
section will be type-level lists.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 94
3.1.5 Type-level lists
Type-level lists of types were common long ago. In 2001,
Andrei Alexandrescu publishes his fundamental book Modern
C++ Design: Generic Programming and Design Patterns
Applied, in which he reimagines the “GoF” design patterns in
terms of the C++ type system. He uses recursive templates for
lists of types as a key mechanism that allows him to implement
the design patterns solely on the type level.
NOTE: “GoF” design patterns are also informally known as
“Gang of Four design patterns.” A famous and fundamental
book titled Design Patterns: Elements of Reusable
Object-Oriented Software describes dozens of
object-oriented design patterns known and widely used in
the software industry at the time. This book is very
influential and, in many ways, groundbreaking for software
design.
LINK Gamma Erich, Helm Richard, Johnson Ralph, Vlissides
John, Design Patterns: Elements of Reusable
Object-Oriented Software
https://www.amazon.com/Design-Patterns-Object-Oriented-
Addison-Wesley-Professional-ebook/dp/B000SEIBB8
LINK Andrei Alexandrescu, Modern C++ Design
https://www.amazon.com/Modern-Design-Generic-Program
ming-Patterns/dp/0201704315
It wasn’t the first occurrence of type-level lists, for sure; they
had seen some usage in Haskell even earlier. What was a novice
in Alexandrescu’s work is the idea that we can do software
design on the type level with all the previous principles applied.
Later, it found its usage in the C++ Boost libraries, most of
which exploit type-level design patterns a lot. So generic
programming has been here for decades; we just need to
formulate it carefully and make it accessible for all.
Rolling out our own simple type-level list mechanism won’t be
difficult if we remember what the usual functional list is. The list
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 95
is a recursive parameterized algebraic data type that has two
value constructors:
data List a = Empty #A
| Cons a (List a) #B
#A List tip
#B List chain
Once we’ve defined it like this, we already have it promoted to
the type level to be a recursive list-like data structure (thanks
to DataKinds). Pseudocode:
type 'Empty :: kind (List a)
type ('Cons a tail) :: kind (List a)
type tail :: kind (List a)
Let’s write the two recursive Description instances for it:
Listing 3.6 Type-level list interpretation
instance Description 'Empty where #A
describe _ = "[]"
instance
( Description a #B
, Description rest #C
) =>
Description ('Cons a tail) where #D
describe _ = describe (Proxy @a) <> " : "
<> describe (Proxy @tail)
#A Base of the recursion
#B Description of the payload
#C Description for the nested type
#D Recursive case
This is quite simple. We deconstruct the 'Cons type-level value
and descend to the nested one which can be either 'Cons or
'Empty. The list of persons may be written as follows:
type HausdorffPerson = 'Person "Felix" "Hausdorff"
type MandelbrotPerson = 'Person "Benoit" "Mandelbrot"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 96
type PersonsList =
Cons HausdorffPerson (Cons MandelbrotPerson Empty)
main = print (describe (Proxy @ManualPersonList))
-- Outputs:
-- Felix Hausdorff : Benoit Mandelbrot : []
Looks like a no-brainer, right? Well yes, because it’s the
simplest implementation possible. I would, however, say that in
Haskell, there are many other custom implementations that
exhibit their own desired properties for list behavior. In
particular, there are various type-level and mixed-level
heterogeneous collections that rely on list-like data structures.
Learning all of this could certainly give us new interesting ideas,
but would lead too far from the actual domain modeling into the
world of pure Computer Science.
We also should think twice if we even need a custom
implementation. Haskell provides a special syntax for usual
lists, so it does for type-level ones:
type StockPersonList = '[ HausdorffPerson, MandelbrotPerson ]
Certainly, there is an empty list ('[]) and the cons type
operator (':) acting like a normal operator but joining types,
not values. We have to enable one more extension for the infix
type-level operators:
{-# LANGUAGE TypeOperators #-}
type StockPersonList =
HausdorffPerson
': MandelbrotPerson #A
': '[]
#A TypeOperators used here
That’s actually it. Interpreting it doesn’t represent a challenge,
so I’ll leave it for your exercise.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 97
3.2 Type-level customization
Customization differs from extensibility.
Extensible solutions suppose that developers are invited to
create new types and put them working under a given
interface. There is a client code that deals with the interface but
is unaware of the new types coming. Extending the set of
suitable types varies the behavior of the client code yet leaves
it unchanged. We’re achieving decoupled systems that are only
loosely related via the extensibility interface.
Customizable solutions give the developer everything to craft a
new behavior without the need to invent something. The given
tools express a flexible and wide superset of the domain, and
whatever logic these tools enable to construct will work
immediately. Customizing the system therefore means creating
new behaviour without extension of the system, and the tools,
which are often DSLs, may be validly called a customization
interface.
Previously, the Automaton type class was the extensibility
interface. We were writing cellular types and instantiating it for
them. There was some instance boilerplate that should have
been written, even if the automata types were identical. Now
we’ll take the customizable approach. There will be only one
predefined type-level eDSL for rules. It will allow setting up a
transition table when the automaton is seen as a state machine
translating one world to another with the cells acting like states.
3.2.1 Case-driven design methodology
What do you think of TDD? Here, TDD stands for Test-Driven
Development, a practice of software development that urges to
write unit tests before the code. In fact, there is a full TDD
cycle, a ritual, that constitutes the TDD methodology:
▪ write a test before writing any meaningful code
▪ ensure the test fails
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 98
▪ write a minimal implementation, even a dummy code
▪ ensure the test passes
▪ rework the code and write a correct implementation
I’ll refrain from discussing the pros and cons of this approach,
especially in the presence of a general debate over TDD in the
industry. I can only say that writing tests before the code is a
good idea, but not necessarily for the reasons TDD states, and
not in the form it proposes.
I write tests because tests are the consumer of the upcoming
functionality, and once you need to formulate a test, you need
to cast a case for it. This, in turn, and we’re in agreement with
TDD here, makes you analyze your domain carefully: what this
code should do and why, what is the best shape for it, and what
are the options. The test-first development practice helps in
clarifying the requirements and shaping the upcoming
functionality in a proper way.
Let me repeat these two good outcomes of the test-first
approach:
▪ clarify the requirements
▪ decide on the design of the subsystem or domain model
This becomes crucial when designing all kinds of
domain-specific languages. A DSL is a convenient coded
representation of a domain first and foremost, and its
ergonomics, its completeness, its mnemonics should empower
and simplify writing business logic, and the only real way to
assess the DSL is to use it. The cycle of this practice slightly
differs from TDD. It doesn’t strictly require writing dummy code
to solely satisfy the tests, but if you decide to do that, the two
methodologies become very similar except mine has two parts:
the design cycle and the implementation cycle.
The design cycle (most important one):
▪ Analyse requirements. Pick a domain notion or a piece
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 99
of business logic and investigate it: what is it, why it’s
needed, when it’s needed, in what form it’s needed.
▪ Design. Imagine a fraction of eDSL for this logic and try to
embody the case in code without worrying it’s not fully
working or is incomplete.
▪ Evaluate and refactor. Assess the design of the code
according to the design principles. Fix problems and
improve the design.
▪ Repeat the design process or proceed with the
implementation.
The implementation cycle can be your preferred way to work, or
it may look as follows:
▪ Write tests. Tests for new functionality will certainly fail.
They may even fail to compile as there is no logic behind
yet. Old tests may require some updates.
▪ Implement. Implement the needed infrastructure and
mechanisms to make the code work and tests pass or
reveal things to redesign.
▪ Redesign. Return to the design process and reevaluate
the case and the subsystem or DSL.
With this workflow, we’re trying to craft a DSL that best suits
our needs, looks nice, and makes the actual business logic code
as clear as possible. From my experience, Case-Driven Design
(CDD for short) as I call it quickly pays all the bills; and also,
designing DSLs this way becomes a lot of fun.
NOTE The Case-Driven Design methodology has one more
optional yet powerful practice that I call Double Usage
Assessment. In short, providing two or more contexts in
which a DSL or an interface is used may easily uncover
some flaws or issues not seen from a single
implementation. Tests of the interface/DSL may be the
second usage context in addition to the actual client code.
Additional implementations of the interface/DSL, although
completely fake or toy ones, may also reveal the design
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 100
suboptimalities in the interface. We’ll soon see this principle
in action in Chapter 5.
Let’s see how it works.
3.2.2 Customizable type-level eDSL
Analyse requirements
Reformulating the Life-like rules in terms of a state machine is
easy when it’s the Born/Survive notation. Figure 3.8 shows the
transition table of such a machine for the particular B3/S23
rule:
Figure 3.8 Game of Life rule transition table in the B/S notation
According to the picture, there are initial states (squares in the
circles in the first column), there are “events” – the number of
alive neighbors around the current cell (black/white fraction),
and the intersection of rows and columns gives us the resulting
state. It follows that if we’re able to put this transition table into
an eDSL then we’ll get a possibility to encode any of the
262144 Life-like rules for free.
Design
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 101
The transition table is a list of transitions for a specific cell
state. Let’s construct a test case using an imaginary type-level
eDSL. It will be a list of two transitions from the initial states to
nothing yet:
type A = 1 -- Alive
type D = 0 -- Dead
type B2S23Transitions =
'[ 'StateTransition A
, 'StateTransition D
]
The StateTransition ADT will roughly look like this:
data CustomStateTransition
= StateTransition
{ cstFromState :: Nat
}
Notice that I use Nat as a type here, but after promotion, it will
become the Nat kind of the only type parameter. Take a look
(pseudocode):
type 'StateTransition st :: kind CustomStateTransition
type st :: kind Nat
Every state is denoted by an integer index to address a possibly
wider class of automata. More than two states are now allowed.
If that wasn’t a requirement, I could certainly have a specific
ADT instead of Nat (pseudocode):
data State = A | D
type 'A :: kind State
type 'D :: kind State
Or even give a specific name to every state:
type Yin = "Yin"
type Yang = "Yang"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 102
Or like this:
data CellState (name :: Symbol)
type Yin = CellState "Yin"
type Yang = CellState "Yang"
This is a tiny part of the domain, yet there are so many design
options to consider, so I just preferred the simplest solution to
all of them.
Evaluate and refactor
The transition code is clearly incomplete. There is no way to say
to what cell it transitions, and under what neighboring
conditions (what column in the table to pick). Let’s postpone
modeling the conditions and at least address the default case
when none of them is satisfied. Although there are several ways
to do that, I find it convenient to have another case in the
transition ADT for it:
data CustomStateTransition
= StateTransition
{ cstFromState :: Nat
}
| DefaultTransition
{ cstDefaultState :: Nat
}
This results in that the board will go dead after every step:
type B2S23Transitions =
'[ 'StateTransition A
, 'StateTransition D
, 'DefaultTransition D -- Will always work
]
It’s not the desired behavior, but we’ll correct it later.
Write tests
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 103
Tests should run these transitions against predefined test data.
The “cross” test sample that figure 3.9 provided for us would be
quite in place.
Figure 3.9 Cross oscillator
Assuming the best, we suppose that the previous data
structures will work, so we just put the cross into Board:
cross1 :: Board
cross1 = Map.fromList
[ ([0,0],0), ([0,1],1), ([0,2],0)
, ([1,0],0), ([1,1],1), ([1,2],0)
, ([2,0],0), ([2,1],1), ([2,2],0)
]
A challenging part follows next because we don’t have any code
able to evaluate the transition table over the board. We
certainly could craft a specific instance of the Automata type
class, and thus connect the application code written previously
to the new approach. You’ll find in the code samples to this
book, it’s done indeed, but there is one more layer in between
the instance of Automata and the evaluation of this type-level
eDSL. I’ll only demonstrate the evaluation (types interpretation)
part of it here. Let’s get a evaluateTransitions' function
with a reminder to visit it later:
evaluateTransitions' :: Board -> Proxy ts -> Board
evaluateTransitions' _ _ = error "not implemented"
It allows us to bake some tests (I use the hspec testing
framework):
cross2Expected :: Board
cross2Expected = Map.fromList ...
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 104
spec :: Spec
spec = it "Cross test case" $ do
let cross2 = evaluateTransitions' cross
(Proxy @B2S23Transitions)
cross2 `shouldBe` cross2Expected
Here, cross2Expected is another board to compare with. The
code will crash and the test will fail, but that’s normal and
corresponds to the idea of TDD well.
What is off with TDD though is that it mandates us to bring an
invalid implementation to solely satisfy the test. It could be, for
example, a board exactly equal to what is expected in the test
so the latter could go green:
evaluateTransitions' :: Board -> Proxy ts -> Board
evaluateTransitions' _ _ = cross2Expected -- FIXME
This test will succeed, but it will be misleading. We normally
treat tests beyond what they’re testing and consider them valid
representatives of a wider class of cases because it’s impossible
to check everything. But here the test is too literate, it verifies
the only test case of the cross we gave it. After a while, with no
other tests provided, we might forget about this hack and
realize that something is wrong while it looks good.
Instead of this doubtful TDD practice, I propose to mark the
tests pending so that we can always see the real state of the
functionality. In Haskell’s hspec framework, it’s enough to
replace it with xit, or put pendingWith into the body of the
case with a meaningful message:
spec = xit "Cross test case" $ ...
spec = it "Cross test case" $ do
pendingWith "Incomplete functionality"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 105
The case gets a mark “under construction”, which will not fail
the test run and will not interfere with other tests while still
keeping us aware.
TIP Marking tests “pending” or “ignored” is a common
feature across the testing frameworks. See Part IV: Rosetta
Stone for more info.
Implement
We should now implement the algorithm, meaning we need to
turn types into magic. We need some type classes that address
their own parts of the eDSL, namely, two of them: one to apply
a specific transition and another one for traversing the list.
You’ll find them in listing 3.7:
Listing 3.7 Interpretation of the state transition eDSL
class ApplyTransition (t :: CustomStateTransition) where
applyTransition
:: Proxy t -- current transition
-> Coords -- current cell coords
-> Board -- previous gen board
-> Maybe Nat -- possible new state of the cell
class EvaluateTransitions (tsList :: [ts]) where
evaluateTransitions
:: Proxy tsList -- transition list
-> Coords -- current cell coords
-> Board -- previuos gen board
-> Int -- current cell state
-> Int -- new cell state
Notice that we specifically declare what kinds of types these
two support (CustomStateTransition and [ts]) to disallow
other usages of the type classes. (For a specific reason, I can’t
easily declare [CustomStateTransition] instead of [ts] in
Haskell; let’s just deal with it.)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 106
When this is fixated, we process every transition with
ApplyTransition:
instance ApplyTransition ('StateTransition st) where
applyTransition _ board coords = ...
instance ApplyTransition ('DefaultTransition st) where
applyTransition _ board coords = ...
And put the recursion over the list into the instances of
EvaluateTransitions:
instance EvaluateTransitions '[] where #A
evaluateTransitions _ board coords oldCell = ...
instance
( EvaluateTransitions ts #C
, ApplyTransition t
) =>
EvaluateTransitions (t ': ts) where #B
evaluateTransitions _ board coords oldCell = ...
#A Base case
#B Recursion case
#C Needed instances
Finally, we update evaluateTransitions' to utilize the type
class-based algorithm. It will traverse the board and call
evaluation for each cell:
evaluateTransitions'
:: EvaluateTransitions ts
=> Board
-> Proxy ts
-> Board
evaluateTransitions' board proxy =
Map.mapWithKey (evaluateTransitions proxy board) board
I won’t reveal the actual algorithmic code because these
implementation details are not that important for our narrative.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 107
All the examples are available in the git repo as well as a demo
application that you can run in your terminal.
Redesign
This is what the whole case-driven design looks like. Seems
we’re on the right road, and the Born/Survive type-level eDSL
is valid, we just need to improve it in a certain way. There
should be a condition for the transition and the resulting cell
state. A condition is the number of neighbors having a specific
state. There can be many enabling conditions for a transition,
for example, 2 or 3 alive neighbors for the Survive rule of Game
of Life. One possible way to model this is a list of conditions:
type Neighbors3 =
'[ 'NeighborsCount A 3
]
type Neighbors23 =
'[ 'NeighborsCount A 2
, 'NeighborsCount A 3
]
But I prefer a less wordy option that is much closer to the
domain notation – list of quantities:
type Neighbors3 = 'NeighborsCount A '[3 ]
type Neighbors23 = 'NeighborsCount A '[2,3]
type B2S23Transitions =
'[ 'StateTransition D A Neighbors3 #A
, 'StateTransition A A Neighbors23 #B
, 'DefaultTransition D #C
]
#A D changes to A when 3 A
#B A remains A when 2 or 3 A
#C Otherwise, it’s D
Two more fields should come into StateTransition, and one
more type-level ADT for cell count should be invented.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 108
Listing 3.8 Complete transition eDSL
data CellCondition = NeighborsCount #A
{ ccState :: Nat
, ccQuantity :: Nat
}
data CustomStateTransition
= StateTransition
{ cstFromState :: Nat
, cstToState :: Nat
, cstCondition :: CellCondition
}
| DefaultTransition #B
{ cstDefaultState :: Nat
}
#A Specific cell quantity
#B Default transition when no conditions are met
When promoted, these two ADTs become (pseudocode):
type 'NeighborsCount ccSt ccQ :: kind CellCondition
type 'StateTransition cstFS cstTS cstCs
:: kind CustomStateTransition
type 'DefaultTransition cstDS :: kind CustomStateTransition
With this language, we can customize many other rules, and
not only Life-like ones. One-state automata (what is it by the
way?), two-state, or more-than-two-state automata, – they all
are possible. However, the eDSL has limitations, too. It doesn’t
address the algorithm for neighbors and doesn’t specify the
type of the field. What if we wanted a torus instead of the
infinite plane? Or even something exotic.
▪ 2D grid of squares (done; might want to support exotic
fields as well)
▪ arbitrary cell states (done)
▪ arbitrary neighborhood (not done)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 109
▪ arbitrary rules (done)
There is room for improvement but the margins of this chapter
are too thin to show everything. At least we’ve discussed how
this approach to customization differs from extensibility, and
what tools we can use to build our nice type-level eDSLs.
3.3 Summary
▪ Type-level genericity means being able to implement some
genericity-like abstraction with user-defined types not
known upfront.
▪ Typically, genericity is represented by type classes, generic
functions, and parametrized data types.
▪ There are many patterns based on type-level genericity.
These are: generic collections, type-level collections (such
as type-level lists), Haskell idioms (such as Functor,
Monoid, Foldable, Semigroup, Applicative, Bifunctor), and
parametrized ADTs.
▪ Empty ADTs with phantom types are best for type-level
tags (or marks) in addition to type-level strings and
numbers.
▪ TypeApplications in a Haskell GHC extension that enables a
shorter syntax for types.
▪ Types in Haskell can be promoted one level up to become
“types of types” also known as “kinds.”
▪ “Kind” is the old name that is made obsolete with
PolyKinds, but it still has real usage in the Haskell
codebases. Also, it’s quite convenient.
▪ Customization is an approach when new behavior can be
added without introducing extra machinery on the client
side.
▪ Customization is often some eDSL that enables a wide
business domain.
▪ Customization and extensibility are two general
approaches to addressing yet-unknown requirements, and
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 110
they can work side-by-side nicely.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 111
Chapter 4
Use case: enforcing
correctness
This chapter covers
▪ What correctness is really about
▪ Static referential integrity
▪ Static operational and structural integrity
With all the similarities between GoL programming and
type-level programming, there are many differences, too.
Cellular automata are cheap to experiment with and don’t aim
to be anything but research and entertainment. You likely won’t
see any business model entirely based on a cell automaton.
Nothing business-critical would depend on how correctly the
developer arranged the cells in the world. It’s difficult to see
any commercial value in running these worlds, and there seems
to be no cell analog of GUI buttons that the costly developers
could paint daily.
In contrast, type-level programming is real in this sense, and it
can be a working horse for businesses. Type-level GUI libraries
might exist, and if you start using them, your buttons and
colors will all be types and only types. Predictably, such a
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 112
library would require hiring professionals skilled above the
middle, making the project much more expensive.
Type-level solutions are never the only viable solution in every
next case, but they attract people quite a lot. Developers,
especially in Haskell, love dreaming about unbreakable type
safety, complete correctness, and formally proven impossibility
of bugs in their programs. They’re often overly dramatic and
creative in predicting the soon and inevitable collapse of the
entire code if it’s not typed enough. Nothing will even work
without our savor, Homotopy Type Theory, and Dependent
Types, its prophet.
The sad truth is that pragmatic type-level programming
requires higher honesty. Statically enforced and verified
correctness is not the final goal of the development.
Type-levelness can’t guarantee the absence of bugs. Type-level
air castles can be very expensive and less justified than other
solutions. After all, we all know that dynamic languages won
the popularity race, and they don’t bother about enforcing
something during the compilation phase. The world hasn’t
collapsed yet, – so statically enforced correctness might not be
the most wanted thing in the Universe.
Correctness is far from a holy grail unless we treat it so. We
must demystify it and debunk the wrong impression it gives of
what functional programming can offer the world.
What is correctness, precisely? How does it differ from type
safety and type-levelness? What’s so important about it? How
can it be achieved with a reasonable effort, and is it worth it
from the cost/benefit point of view? Let’s be honest in this
chapter and figure this out.
4.1 Correctness is about meaning
Does correctness occur in our code at the very moment we
start thinking of type-level programming? Or will it emerge
immediately once we write type A = B? Or do we probably
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 113
need to cast a spell to lift it up? “Wingardium Curiosa”? How do
we define the point in the project’s history when we can say:
“Congratulations, everybody, we now have correctness!”?
The very term “correctness” is quite a vague concept. We can
define it in many ways, and not all of these definitions are
equally fine. My main point is that when we program, we
encode some essential idea, some natural meaning, and the
meaning is not a technically measurable phenomenon; thus, it
cannot be achieved automagically. Ultimately, “meaning,” not
“correctness,” is what we need to materialize in code, and we
can only say we’ve succeeded if the resulting application does
what it should do and helps the users to solve their tasks.
4.1.1 Type-safe vs correct
Many Haskell libraries expose a more or less typed interface.
Most of them promote type safety, which is usually achieved by
disallowing invalid type conversions. What invalidity is and how
to disallow it is decided for each specific library.
DEFINITION Type safety: inability to perform wrong type
conversions, to construct incorrect types, or to combine
types and values that are not supposed to be combined.
Some design patterns are intended to provide type safety.
Newtypes we’ve seen earlier in Chapter 1 are such. They help
us not to confuse identical-by-construction but
different-by-meaning entities such as FilePath, a person’s
name, which both happened to be strings.
newtype Name = Name String
newtype FilePath = FilePath String
The following function is, therefore, type-safe:
writeNameToFile :: FilePath -> Name -> IO ()
writeNameToFile (FilePath p) (Name n) = writeFile p n
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 114
However, on closer inspection, using newtypes doesn’t make
the dependent code bug-free. We can easily misplace the name
and the file path on the caller side:
bob = Name "Bob"
file = FilePath "{#%" #A
main = writeNameToFile file bob
#A Meaningless
This will compile finely but fail when run. File paths are not
allowed to have these symbols. It’s incorrect because we
humans know it is, but the compiler sees this code as
acceptable.
Ironically, swapping the two strings would make the program
technically correct even from the OS point of view, but it’s yet
meaningless because it’s not what we meant:
bob = Name "{#%" #A
file = FilePath "Bob"
main = writeNameToFile file bob #B
#A Technically correct but meaningless
#B Type-safe call
It’s technically correct because it might make sense in a
fictional universe. I can easily imagine a novel story with actors
named like this: "/#%," "{/}," "<>#," but I guess this society
would have its own rules for file path naming, so it might be,
writing into the "Bob" file will crash.
So, technical correctness and type safety don’t imply
meaningful code. We don’t measure how well functions are
protected from mistakes—we measure how well our code solves
the tasks. Meaning should define correctness here, not vice
versa.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 115
4.1.2 Type-level vs correct
The next tempting idea would be to leverage more type safety
and correctness by lifting the code to the level of types. Bonus
points if we can establish a system of formal proofs that
anticipates and prevents incorrectness in the initial phases and
does so with Math’s inevitability and rigor. Why otherwise write
in a strongly statically typed language such as Haskell? There is
no other goal but to finally turn the program into a math proof!
Indeed, people were vastly dissatisfied with Haskell’s
longstanding issue, “FilePath is simply String.” They developed
numerous libraries to address this critical and pressing flaw
while neglecting the absence of necessary libraries.
Let’s look at the strong-path library, for example. The library
explicitly distinguishes absolute and relative paths using
type-level tags in the form of empty ADTs. This allows us to
ensure at the compile time that these two combinations are
possible:
relative path </> relative path => relative path
absolute path </> relative path => absolute path
And these combinations should be rejected by the compiler:
relative path </> absolute path => compilation error
absolute path </> absolute path => compilation error
Figure 4.1 Alignment table for relative and absolute paths
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 116
Here is the code that obtains the absolute path of the /home
directory:
Listing 4.1 Obtaining the absolute path with strong-path
import StrongPath (Path, System, Abs, Rel, File, Dir, (</>))
data HomeDir #A
type HomeAbsPath = Path System Abs (Dir HomeDir) #B
getAbsHomeDirPath :: IO HomeAbsPath
getAbsHomeDirPath = ... #C
#A Type tag
#B Absolute path to /home
#C Getting the path from the user
Here we have the type tag HomeDir and the HomeAbsPath
type that uses some type-level eDSL to describe the
absoluteness property of the path. We can also do the same for
relative paths, for instance, a file from the home directory:
data UserFile
type UserFileRelPath = Path System (Rel HomeDir) (File UserFile)
getRelUserFilePath :: IO UserFileRelPath
getRelUserFilePath = ...
Combining the absolute path to /home and the path to the file
in that directory will be, therefore, correct and will give us the
full path to the file:
absHomePath <- getAbsHomeDirPath
relUserFile <- getRelUserFilePath
let fullUserFilePath = absHomePath </> relUserFile
Other usages of the same variables will lead to a compiler error.
-- Won't compile: two abs paths cannot be combined
-- let invalidPath1 = absHomePath </> absHomePath
-- Won't compile: abs path cannot follow rel path
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 117
-- let invalidPath2 = relUserFile </> absHomePath
In this sense, the library enforces some correctness and type
safety at the cost of slightly complicating its usage. This might
or might not be appropriate depending on the complexity
budget your project can afford and whether it’s critical to guard
against these incorrect cases when dealing with relative and
absolute paths. However, relying on this library will not make
the code fully unbreakable because the problem is the factual
paths, the validity of which we can’t technically verify. The file
may be absent, we may input a different one, or we may
misunderstand the requirements and occasionally have not
what we want.
Neither type-level magic nor technical correctness can prevent
meaningless data. Data-related errors are still possible; only a
human can tell the truth.
NOTE Appendix C The Mythologized Correctness will
debunk more myths about correctness, highlight how
pursuing technical excellence intentionally or
unintentionally misplays meaning, and explain how an
impermissibly high accidental complexity is tolerated no
matter what.
Although we want more tools to advance the principle Make
invalid states unrepresentable, we should do it systematically,
like engineers. In the next section, we’ll learn how to embed
more correctness into the code without making it overly
complicated.
4.2 Static integrity
In relational databases, there is an important idea known as
referential integrity. It occurs when a row depends on the other
row by borrowing its primary key as a foreign one. The two
can’t be inconsistent, as enforced by the DB engine. Any
attempt to create a dependent row with a foreign key pointing
to the nonexistent parent record will be rejected at the moment
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 118
of an SQL evaluation. This mechanism works for many purposes
and various sorts of relations, but it is especially valuable when
there is a fixated list of options to choose from. We call such a
list a dictionary (or enumeration), and all the dependent tables
should point to it to have consistent data with some choice.
We’ll do the same for our domain model with one significant
difference. While databases’ guarding referential integrity
comes into action at runtime, we’ll make a similar guarding
mechanism that triggers at the compile time, much before the
evaluation of the code. We’ll do this, however, by carefully
avoiding various temptations, the first of which is
re-implementing the relational algebra on the type level. From
my observations, once a developer decides to implement a
math theory to achieve some sort of correctness, the business
domain starts being off, and the focus shifts to detached,
abstract things. As a result, the domain dissolves in this code
and becomes barely visible.
We’ll embed static referential integrity into the domain
modeling, but we will do this as engineers by respecting the
business domain and following some guiding principles. We
always need a guiding principle to stay on the right path.
4.2.1 Strengthening the domain model
Bringing more statically verified meaning into the domain model
typically starts by searching for domain notions that could be
revisited to strengthen the whole construction. Sometimes, it’s
just missing parts that, once typified, cut off some invalid cases
and make the model more explicit, and sometimes, it’s the
existing data structures that have room for improvement.
There is, for example, a list of state transitions that I’m not
fully satisfied with. The way we model the transitions is not
consistent. We are mandated to put a default case right after
regular transitions and treat it specially:
type B2S23Transitions =
'[ 'StateTransition D A Neighbors3
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 119
, 'StateTransition A A Neighbors23
, 'DefaultTransition D
]
It’s triggered if other transitions fail, but if any succeed, the
default case will just hang in the air with no action. It also
doesn’t protect us from doing several incorrect writings, such
as:
type Transitions1 =
'[ 'DefaultTransition D #A
, 'StateTransition D A Neighbors3
]
type Transitions2 = #B
'[ 'StateTransition D A Neighbors3
]
#A Wrong: the default case precedes others
#B Wrong: the default case is missing
The default case should never be absent, as it can happen when
it optionally belongs to the list. We should ship it
unconditionally, in addition to the list. Like in the usual
programming, there is only one option: tuples. Let’s call this
structure “step”:
type CustomStepTuple = (Nat, [CustomStateTransition])
What is the meaning of that Nat number? We, authors of this
code, know it’s the index of a state, but how will our colleagues
know? We fix it by giving it a name:
type StateIdxNat = Nat
type CustomStepTuple = (StateIdxNat, [CustomStateTransition])
Still, it’s not fully obvious because what index is this? The
“defaultness” property can be and should be communicated as
well. Adopting the “newtype” approach makes our intentions
explicit:
newtype DefaultState = DefState StateIdxNat
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 120
type CustomStepTuple = (DefaultState, [CustomStateTransition])
Lifted to the upper level, this data type will give us the following
transition definitions (notice the spaces preceding each tick):
type NullStepTuple = '( 'DefState D, '[] )
It’s an empty type-level table with the only default case
specified that, when applied, nullifies all the cells. This is the
analog of “zero” for all the GoL-like cellular automata.
Type-level tuples feel quite the same as value-level ones, but I
prefer having a named tuple instead, also known as a product
type, aka half of ADT. In my opinion, it looks better thanks to
its fields:
data CustomStep = Step
{ csDefaultState :: DefaultState
, csTransitions :: [CustomStateTransition]
}
Now, the transition table feels more accurate and consistent:
type GoLStep = 'Step ('DefState D)
'[ 'StateTransition D A Neighbors3
, 'StateTransition A A Neighbors23
]
But is this the only possible improvement? What other flaws
does the eDSL have? Can you develop a table that has no
meaning yet? There are many trapped paths here. Some
examples are duplicated transitions, unused and forgotten
states, unknown states, and so on. The following InvalidStep
is invalid because it is non-exhaustive: it has two transitions
from the same D state but doesn’t specify what to do with the A
state:
type InvalidStep = 'Step ('DefState D)
'[ 'StateTransition D A Neighbors3
, 'StateTransition D A Neighbors23
]
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 121
Let’s see what we else need in our domain before preventing
these wrongly constructed domain data becomes possible.
4.2.2 Static and volatile domain notions
As a next step, we should think of what the analog of dictionary
tables would look like. By having a source of truth for cellular
states, the compiler can consult with it and verify that the rules
and algorithms all reference correct states.
The dictionary will be just a type-level list of states specific to a
certain automaton; see listing 4.2:
Listing 4.2 Source of truth for states
type A = 1 #A
type D = 0
type LifeLikeStates = #B
'[ 'State "Alive" A
, 'State "Dead" D
]
#A “Primary keys”
#B “Dictionary table”
Here, I introduced a new ADT for states:
type StateNameSymb = Symbol
data CustomState = State
{ csName :: StateNameSymb
, csStateIdx :: StateIdxNat
}
The dictionary LifeLikeStates contains a unique string name
in addition to the integer index. Both will have the same power
to identify a cell state, so both can serve as “primary keys”.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 122
Figure 4.2 CustomState type and kind
To always have access to this dictionary, we should carry it with
the domain types it has relation to. We could, in principle, just
put the states into a domain type like the following:
data CustomStep = Step
{ csStates :: [CustomState] #A
, csDefaultState :: DefaultState
, csTransitions :: [CustomStateTransition]
}
#A States dictionary
But the nature of the two notions differs quite much. Steps and
state transitions are customizable for each rule; they are
volatile, whereas the dictionary is static and frozen in time. It’s
easier to see this for the LifeLikeStates table because we
define it once for all the Life-like automata and do not expect it
to vary from rule to rule. Finding this peculiarity in the domain
notions is a guiding principle that can help us to separate the
two kinds of data and treat them differently. It is better if they
are visually distinct, too.
Let’s express volatile domain notions as type-level ADTs and
static dictionaries as type parameters of volatile entries. This
will be self-representative enough to help the compiler verify
the integrity soon. In listing 4.3, an updated CustomStep type
is shown; see also figure 4.3:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 123
Listing 4.3 Static and volatile domain notions
data CustomStep (states :: [CustomState]) #A
= Step
{ csDefaultState :: DefaultState
, csTransitions :: [CustomStateTransition]
}
type GoLStep = 'Step #B
@LifeLikeStates #C
('DefState D)
'[ 'StateTransition D A Neighbors3
, 'StateTransition A A Neighbors23
]
#A Type parameter for a static domain notion
#B Volatile domain notion
#C Specifying the static domain notion
Figure 4.3 CustomStep type and kind
NOTE This separation might be difficult in other
languages that lack many of Haskell's type-level features.
At least, there are some interesting solutions in Scala,
C++, and Rust. See Rosetta Stone for more info.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 124
We could also passthe dictionary further into the
CustomStateTransition values because they reference
states, too. Something like this (pseudocode):
type GoLStep = 'Step
@LifeLikeStates
('DefState D)
'[ 'StateTransition @LifeLikeStates D A Neighbors3 #A
, 'StateTransition @LifeLikeStates A A Neighbors23
]
#A Passing the dictionary further
However, it turns out that the StateTransition values are
not very independent and, therefore, won’t participate in the
cellular mechanisms without the whole step. It should be
enough to parametrize CustomStep only.
Keeping a dictionary within the primary domain notions and
avoiding overparametrization for secondary ones would work as
the second guiding principle here.
We should enforce the table from listing 4.3 onto the state
transitions. The compiler should take the states and the
transition table and ensure that all states are properly utilized.
It can also verify that no unknown state has infiltrated the
transitions and that all the transitions generally make sense.
4.2.3 Static operational integrity
Strictly speaking, past ADT strengthening and its type-levelness
didn’t bring anything beyond what the similar mixed-level terms
may give us. There is not much difference between these
invalid notions, one of which is a type and another one is a
regular constant:
type InvalidStepType = 'Step @LifeLikeStates ('DefState D)
'[ 'StateTransition D A Neighbors3
, 'StateTransition D A Neighbors23
]
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 125
invalidStepConstant = Step @LifeLikeStates (DefState 0)
[ StateTransition 0 1 neighbors3
, StateTransition 0 1 neighbors23
]
These tables are twins, and they share genetic abnormalities.
Nothing special had been achieved with the type-levelness yet.
However, with some additional verification mechanisms on the
type level, the first invalid table can be statically discarded, and
the second can’t. By implementing the verification mechanism,
we’ll finally have a distinguishing factor for the level of types
and a proper justification for extra complexity.
The core of the mechanism will be a type class Verify:
class Verify tag where
{- nothing here - empty type class -}
It’s an empty type class that has a single type parameter tag.
Instances of this type class will include various checks for
various data types, such as duplicate records, non-exhaustive
lists of entries, inconsistencies in fields, and many others. The
tag type parameter is responsible for this: a specific check for a
specific user-defined type value. In listing 4.4, three such tags
are presented. These are separate ADTs parametrized by data
to be verified:
Listing 4.4 Tag types to represent various checks
data StatesNotEmpty (states :: [CustomState])
data AtLeastTwoStates (states :: [CustomState])
data StatesAreUnique (states :: [CustomState])
The checks are:
▪ the states dictionary is not empty;
▪ there are at least two states in the dictionary (this one
makes the previous check redundant);
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 126
▪ the states in the dictionary are all different and unique.
“States are not empty” validator is very simple; we just need
an instance that pattern-matches over an at-least-once-headed
type-level list:
instance Verify (StatesNotEmpty (s ': ss)) where
That’s the only available instance, so the empty states type
parameter will trigger the compile error:
No instance for (Verify (StatesNotEmpty '[]))
arising from a use of ‘iterateWorld’
These checks and some others validate the dictionary itself
because we should always be sure that the source of truth
doesn’t lie to us. When it’s done, we can, for example, verify
that the default state correctly references one of the dictionary
states. I have the following validator for this:
data DefaultStateIsReal
(d :: DefaultState)
(ss :: [CustomState])
This one isn’t self-containing and relies on a more generic one.
It first unwraps the newtype cover and then passes the index
into the subsequent check. See listing 4.5:
Listing 4.5 Invoking validators from validators
data StateIsReal #A
(s :: StateIdxNat)
(ss :: [CustomState])
instance
( Verify (StateIsReal defIdx ss) #B
) =>
Verify
(DefaultStateIsReal ('DefState defIdx) ss) #C
where {- nothing here -}
#A General validator
#B Invoking another validator
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 127
#C Deconstructing the default value
The DefaultStateIsReal validator was easy, but
StateIsReal is not. I omitted its internals here only to have a
deeper discussion later.
For the integrity mechanism to start working, we should trigger
all the checks somewhere in the code. There are many options
here, but I integrated the verification into the implementation
layer in my code. Here, I have a type class, MakeStep, that
converts a type-level CustomStep into a value-level
representation suitable for the evaluation:
class
MakeStep (step :: CustomStep (states :: [CustomState])) where
makeStep :: Proxy step -> (Board -> Board)
This type class (and others) is a further evolution of the
implementation logic we’ve developed. I use it to construct a
step function over a board that I then run in code and tests.
The developer is free to define any rules they want, and the
compiler will be silent, but when it comes to evaluation, the
type class and its only instance will guard against invalid cases.
In listing 4.6, you can see how to run all the checks from a type
class instance. It’s clear and convenient; you can turn checks
on or off if needed.
Listing 4.6 Triggering the validators
instance
( MakeCellUpdate ts #A
, Verify (StatesNotEmpty states) #B
, Verify (AtLeastTwoStates states)
, Verify (StatesAreUnique states)
, Verify (StateNamesAreUnique states)
, Verify (DefaultStateIsReal def states)
, ('DefState defIdx) ~ def #C
, KnownNat defIdx
) =>
MakeStep ('Step @states def ts) where
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 128
makeStep _ proxy board = ...
#A Auxiliary type class
#B Verifications
#C Deconstruction and type variable definition
Notice that I can deconstruct compound types in the constraints
and even introduce type variables for specific parts. Here, the
def type variable becomes deconstructed, and a new defIdx
type variable occurs. The tilda (~) plays the role of the
equation: two sides are the same type, just different
representations, pretty much like the value level with its
variable binding.
Let’s see how helpful it can be for making more sophisticated
checks and type-level interactions.
4.2.4 Type-level validators
The StateIsReal validator accepts an index of a state and the
source-of-truth-table:
data StateIsReal (s :: StateIdxNat) (ss :: [CustomState])
This validator will fail if the table doesn’t contain the given
index, like here (pseudocode):
type InvalidStates = '[ 'StateTransition 0 0 Neighbors3 ]
-- Won't compile - doesn’t have index 1:
-- Verify (StateIsReal 1 InvalidStates)
The verification algorithm traverses the table and finds the
corresponding state record, if any. We only need to implement
the happy path so that any absent error path could fail the
compilation. Let’s take a look at the instance. It doesn’t contain
much stuff, yet it requires two extra GHC extensions:
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 129
instance
( StateIdxInList s ss ~ 'True #A
) =>
Verify (StateIsReal s ss) where #B
{- nothing here -}
#A FlexibleContexts here
#B FlexibleInstances here
As you can see, there is no list deconstruction here. Instead,
another constraint is invoked: StateIdxInList s ss should
result with a type equivalent to 'True, the only condition for
the constraint to succeed. The FlexibleInstances extension
removes limitations from the type class mechanism that
disallow compound types like our tags and permits full-scale
pattern-matching. FlexibleContexts does the same for the
constraints syntax and logic.
Although the code looks simple, it is difficult to see what is
StateIdxInList, exactly. It should do something and return a
type because the tilda operator, known as type equality,
compares types and only succeeds if identical. In our code, the
types should be of the Bool kind, either 'True or 'False. So,
how does StateIdxInList give us a type? Here, I used
another tricky Haskell feature known as type families.
TIP Other languages might have something similar to type
families, but they solve this task mostly differently. See Part
IV: Rosetta Stone for more info.
We must traverse the state list recursively and compare its
index with the inspected one. With the StateIdxInList type
family, I can deconstruct the list as a compound type and
pattern-match over its parts. There are certainly the base case
and the inductive case for the recursion:
type family
StateIdxInList (s :: StateIdxNat) (ss :: [CustomState])
:: Bool where #A
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 130
StateIdxInList _ '[] = 'False #B
StateIdxInList s ('State n s ': _) = 'True #C
StateIdxInList s (_ ': ss) = StateIdxInList s ss #D
#A Type family returns a type of the Bool kind
#B State not found
#C State is found
#D Inductive case
Here:
▪ StateIdxInList is a type family;
▪ (s :: StateIdxNat) is a type, the first parameter (state
index to check);
▪ (ss :: [CustomState]) is a type, the second
parameter (the list of states);
▪ StateIdxInList :: Bool means the result type of this
type family should be of kind Bool.
Type families, StateIdxInList including, can be constructed
with type parameters (“invoked”) and deconstructed with
type-level pattern-matching:
StateIdxInList _ '[] = 'False
StateIdxInList s ('State n s ': _) = 'True
StateIdxInList s (_ ': ss) = StateIdxInList s ss
The syntactic difference between ADTs, parametrized types, and
regular functions is thin enough to bring some confusion around
type families. Compare the code above with a similar function:
stateIdxInList _ [] = False
stateIdxInList s (State n s : _) = True
stateIdxInList s (_ : ss) = stateIdxInList s ss
There is little difference, but a type family is a type-level
mechanism, while the function operates on the value level. This
similarity, by the way, is why people sometimes refer to such
type families as functions over types. Calling StateIdxInList
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 131
and feeding it with type arguments will result in another type
we can utilize from type families or constraints.
This is not everything Haskell’s type families can do, and it’s not
often clear how they work, so we’ll touch on them again when
they are unavoidable to achieve some effects. Otherwise, I
would argue to be careful with type families and try not to use
them because this feature has a bad developer experience,
brings a lot of accidental complexity, and obscures the compiler
messages all the time.
Let’s briefly consider another validator, StatesAreUnique.
data StatesAreUnique (states :: [CustomState])
This one verifies if the very dictionary is a legitimate source of
truth by ensuring that the indexes don’t repeat; they are
unique (it ignores the names). This sample will be rejected:
type SameStates =
'[ 'State "A" D
, 'State "D" D
]
From all the uniqueness-checking algorithms, we can only
implement purely functional and immutable ones because
everything we have on the level of types is construction,
deconstruction, type variables defining, and type equality
checks. Our algorithm will have O(n^2) complexity and act
simply: it will store elements one by one into an auxiliary list if
those elements are absent. Ultimately, all the elements should
migrate from the main list into the auxiliary one.
We first pass two trivial cases dealing with no elements and
with a single element in the list:
instance Verify (StatesAreUnique '[]) where
{- nothing here -}
instance Verify (StatesAreUnique (s1 ': '[])) where
{- nothing here -}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 132
To move further, we need an extra type that will carry verified
elements and the rest:
data StatesAreUniqueCheck
(verified :: [CustomState])
(toVerify :: [CustomState])
We then start the algorithm for two or more items in the source
list by putting the first element into verified, and the tail into
toVerify:
instance
( Verify (StatesAreUniqueCheck '[s1] (s2 ': ss)) #A
) =>
Verify (StatesAreUnique (s1 ': s2 ': ss)) where
#A Start the uniqueness algorithm
The recursive algorithm happens for StatesAreUniqueCheck
and continues until the end is reached. Therefore, it needs a
base and an inductive cases:
instance Verify (StatesAreUniqueCheck checked '[]) where #A
{- nothing here -}
instance
( Verify (StateNotInList s checked) #B
, Verify (StatesAreUniqueCheck (s ': checked) ss) #C
) =>
Verify (StatesAreUniqueCheck checked (s ': ss)) where
{- nothing here -}
#A Base case
#B Additional check
#C Recursive case
This validator demonstrates that we can have as complex
type-level algorithms as we want and even call some validators
from others when needed.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 133
4.2.5 Static structural integrity
So far, validating the integrity was operational: we declared a
bunch of algorithmic validators that could traverse our data
structures and reject incorrect ones at compile time. However,
on closer inspection, some of these validators verify integrity
that can be structural, avoiding any type-level algorithms. Try
to guess which:
data StatesNotEmpty (states :: [CustomState])
data AtLeastTwoStates (states :: [CustomState])
data StatesAreUnique (states :: [CustomState])
data StateNamesAreUnique (states :: [CustomState])
data DefaultStateIsReal
(d :: DefaultState)
(ss :: [CustomState])
Indeed, the two validators, StatesNotEmpty and
AtLeastTwoStates, sound like it should be possible to
strengthen the domain model once again and remove them
without losing any correctness guarantee. The list must have
two states, meaning it will be non-empty automatically, so we
just need to embed a twice-non-empty list structure into our
domain entity. Twice-non-empty or just non-empty lists are
common guests in such a defensive domain modeling, and they
are easy to define:
data CustomList1 a = List1 a [a] #A
data CustomList2 a = List2 a a [a] #B
#A Non-empty list
#B Twice-non-empty list
We then simply replace a regular type-level list with it and
enjoy the result:
Listing 4.7 Structural integrity
data CustomStep (states :: CustomList2 CustomState) #A
= Step
{ csDefaultState :: DefaultState
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 134
, csTransitions :: [CustomStateTransition]
}
type LifeLikeStates = 'List2 Alive Dead '[] #B
#A Twice-non-empty list
#B At least two states
Certainly, more places in the domain model might be suitable
for such a correctness mechanism. For example, a list of
neighbors for a specific transition should have at least one item.
Compare the two:
type Neighbors3 = 'NeighborsCount A '[3 ]
type Neighbors3 = 'NeighborsCount A ('List1 3 '[])
There is no possibility of making a structural error here. We
must provide an item – the number of neighbors of a certain
state. We could, however, be mistaken about the number itself:
type Neighbors3 = 'NeighborsCount A ('List1 333 '[])
We don’t have so many – 333 – neighbors in Life-like
automata; the maximum number is 8. Going even further, we
could, in principle, ensure this invariant with structural integrity.
We could only allow the neighbor number directly encoded by
the choice type (pseudocode):
data CustomNeighborsCount
= NsCount1 | NsCount2 | NsCount3 | NsCount4
| NsCount5 | NsCount6 | NsCount7 | NsCount8
type Neighbors3 = 'NeighborsCount A ('List1 'NsCount2 '[])
This might work, but it seems I’ve confused NsCount3 with
NsCount2 here, and the compiler couldn’t prevent this! Maybe
this could be solved by encoding the properties of the
neighborhood as another source-of-truth data structure and
then validating against it. Right? Probably not, but it will no
doubt increase the complexity of domain modeling.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 135
Indeed, structural integrity enforces the principle Make invalid
states unrepresentable directly, but it also has several
drawbacks and circumstances that must be taken seriously:
▪ Having more structural integrity makes the domain model
much more complex and wordy. Certainly, List2
communicates a specific idea and makes the model more
explicit, but at the same time, the syntactic noise grows
quite much in the samples.
▪ Many invariants can’t be easily encoded. Or, at least, they
will require more type-level tricks, such as type-level
number arithmetics and sophisticated helper data
structures. This leads to a higher accidental complexity
overall. Is this trade correctness for complexity worth it? It
highly depends on the domain, but it most likely isn’t.
▪ The initial premise of a domain's invariants might need to
be corrected. For example, what’s wrong with a one-state
cellular automaton? It won’t do anything useful, but by all
means, it deserves to exist as a trivial case. But
embedding the invariant “at least two states” will prevent
such cases, and we’ll have to redo a lot of code later.
Maybe it’s better to introduce operational integrity and turn
it into a structural one when the understanding domain is
good enough.
▪ It’s not possible to switch off structural integrity. Yes, that’s
the whole point, but sometimes, we’d like to experiment
with the domain to get to know it better. In contrast,
operational integrity can be occasionally switched on and
off, which is much cheaper and probably more flexible.
▪ Simultaneously, static structural integrity can lead to more
user-friendly compile errors, though this tends to be highly
specific to each case. For instance, Haskell offers
techniques to enhance error messages in type classes;
however, the issue remains a significant challenge. No
programming language I’m familiar with excels at
clarifying type-level compiler messages. Yet, I must
acknowledge that Haskell’s type-level programming can be
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 136
as bewildering as it is advanced compared to other
languages.
The techniques of correctness we’ve covered here do not
constitute everything we have, and there are many more to
discover. You can proceed to Appendix C The Mythologized
Correctness, which discusses several additional topics, such as
correctness in programming languages, libraries, and
domain-specific languages. However, we've spent too much
time learning to write correct programs. Now, we should focus
on useful ones.
4.3 Summary
▪ The term “correctness” in scientific papers has an abstract
technical definition that has nothing to do with the actual
meaning that we’re required to implement in real
programs.
▪ Meaning is not a measurable term; therefore, it cannot be
statically proven as a Math theorem. But it can be tested
to some degree with tests.
▪ “Type-safe” and “type-level” don’t mean “correct.”
▪ Type safety ensures the types that don’t match will never
be found together.
▪ Type safety doesn’t have to be checked statically at
compile times. There is some room for type safety in
dynamic languages, too.
▪ Type-levelness only refers to programming with types and
will not bring more correctness or meaning if done to tick
the box.
▪ Domain notions can be separated into two categories:
static ones that keep their shape during a significant chunk
of code and volatile ones that may be customized for
various cases.
▪ Referential integrity for coding is a technique similar to
referential integrity in relational databases that allows a
source of truth for a domain notion to be used for further
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 137
validation of other data against it.
▪ Static operational integrity is some algorithm on the type
level that verifies the integrity of a type-level value.
▪ Static structural integrity follows the principle Make invalid
states unrepresentable by embedding more invariants and
properties directly into the domain model on the type
level.
▪ Type-level correctness enforcement severely impacts
accidental complexity and, thus, should be done with care
and understanding.
▪ It’s quite pragmatic to be the guard of meaning, not the
worshiper of correctness.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 138
Part II
Architecturing Type-Level
Applications
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 139
Chapter 5
Application architecture
This chapter covers
▪ What is software architecture
▪ How to apply Double Usage Assessment practice
▪ What architecture layers exist
▪ How to organize project structure
The Game of Life application we’ve established previously
reveals a glimpse of the bigger knowledge that hasn’t been
addressed yet. To make different parts of the application work
together, we adopted various techniques, such as valuefication
and existentification for joining type-level and value-level code,
and created a couple of interfaces to separate the two. We even
introduced several architectural layers, such as domain,
business logic, implementation, although our primary concern
was type-level programming, not high-level application design.
We’ll get back to the use cases soon. In this chapter, we’ll
investigate application structure and discuss what works best
for code with a massive involvement of types. Type-level eDSLs
are just a small part of the application, and many ways exist to
introduce them into the project. Everything should have its
proper place, and we should learn what means “proper” here.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 140
5.1 Approaching software architecture
Whenever I write about software architecture, I find myself
struck by the Impostor Syndrome: the subject is too big, I’m
too small, I know so little, and yet I want to be called a
software architect. The ocean of programming doesn’t scare me
this much, even if we assume it’s bigger than the ocean of
software architecture, in which I’m not quite sure. The bulk of
software architecture intimidates me not only because of its
size but also because there are no solutions that are remotely
perfect, and there are no straight paths that would be free from
various tradeoffs.
And yet, there are topics of software architecture we should
discuss to have common ground.
5.1.1 Architecture levels
Developing systems at large means developing some kind of
architecture. There are various architectures out there. Most of
them are outside the software developer's scope as they relate
to business organization, infrastructure, security, and finance.
Some types of IT architecture are about organizing projects,
teams, or the development environment. Finally, there are
software architectures that directly relate to us, software
engineers, and should be our concern during development. I’m
talking about solution architecture and application architecture.
DEFINITION Solution architecture involves the design and
management of a complex solution that integrates
multiple applications, systems, and technologies to solve
a broader business problem. It outlines the technical
framework, including hardware, software, network
resources, and services, required to develop, deploy, and
manage the solution in alignment with business
requirements and constraints.
DEFINITION Application architecture is the structural
design of software applications, focusing on the
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 141
organization of the application's components, their
interactions, and the principles guiding their design and
evolution. It addresses concerns like extensibility,
scalability, reliability, maintainability, and performance
within the context of a single application or a group of
related applications.
These two can be layered like a cake, see figure 5.1:
Figure 5.1 Software architecture levels
Type-level metaprogramming, functional programming,
object-oriented approach, and other programming paradigms
don’t matter at the solution architecture level, giving way to
whole systems and their interactions. Some requirements for
exact programming style may be considered on the application
architecture level, for example, “here we have an interop
between F# and C# code, so this component should be a C#
class,” or “this will be a functional reactive library,” but it’s not
quite common and is highly situational. More likely, we’ll see big
application blocks such as components, subsystems, modules,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 142
interfaces and implementations. Finally, when zooming in on
the architecture, we encounter specific parts of the application
and can discuss their design in programming terms. This is
where the difference between programming styles and
approaches starts to matter much. See figure 5.2:
Figure 5.2 Levels of code and programming approaches
In some sense, type-level rules for cellular automata are our
component, and at the same time, they are some code. I
marked the correspondence on the picture. However, I haven’t
heard of type-level modules yet, and it’s difficult for me to
imagine a type-level architecture. What might this be? Let me
speculate.
Type-level architecture might mean that all the programming
code is type-level, and everything is types. In theory, I can
build such a program in Haskell, and I would not even need the
main function if I process and run all the code in compile time
with Template Haskell. This metaprogramming feature allows
input-output during compilation, and in theory, we can simulate
F#’s type providers with it (obtaining types from externally
stringified schemas). I can also encode modules. This fully
type-level code might and will require many more tricks and
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 143
hacks, and its complexity, I’m sure, will skyrocket. Don’t think
we need it.
We also don’t need to discuss solution architecture: general
approaches (such as fault-tolerant architectures), architectural
design patterns (such as CQRS – Command and Query
Responsibility Segregation), and practices (such as using
message brokers for reliable communication), although these
two levels should be considered together because the decisions
made at the solution level greatly affect the application level.
We’ll, however, focus on the application architecture and learn
how to make decisions that are well-informed, responsible, and
conscious.
5.1.2 Double Usage Assessment practice
Among various design practices, one that I especially value can
reveal design issues and validate design ideas. Suppose there is
an interface to some subsystem. Is it designed well? How can
we know?
First, we can examine it in terms of the SOLID principles. For
example, out-of-scope responsibilities (the ISP principle
violation) and redundancy (the SRP principle violation) worsen
the interface and negatively impact the code.
Second, and this is my best practice, we can write independent
client code for the interface and put it into another context,
giving us another view angle to spot the problems. This practice
is effective because it helps us break out of "tunnel vision" and
escape the mental rut we fall into during development. Let’s call
this practice Double Usage Assessment, although it closely
aligns with the ideas of Rubber Duck Debugging and
Dogfooding.
Rubber Duck Debugging is an approach to explaining the details
of your creation to someone from the outside or even to a
rubber duck. It was noticed that doing this helps you to
understand your creation deeper, sometimes to your own
surprise. This principle, I believe, works because it forces our
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 144
brains to switch into the teaching mode. Teachers confirm this:
“The best way to learn something is to teach it” (what I actually
do by writing my books!). Teaching something means looking at
things from different angles and selecting the most effective
one, which is not necessarily the most accurate. Quite often,
deliberate simplification (another perspective) for your listener
gives you many more insights than you could imagine. This is
where the Double Usage Assessment meets the Rubber Duck
Debugging method.
In turn, dogfooding is the practice of consuming your own
product. In this case, you become your own client, which also
impacts how you perceive it. Needless to say, this is also
acceptance testing made less formal. It’s a double usage
assessment literally: there is you, and there is a real client,
hopefully. Using our own products leads us to think about them
from additional perspectives – those ones we are mostly
uninterested in while we are mere developers.
It seems that the Double Usage Assessment practice is
universal and applicable on any level. It includes double-using
interfaces, DSLs, subsystems, libraries, and whole systems. The
Double Usage Assessment practice converges with the
Case-Driven Design methodology: every time you write a test
for something, you put it into a slightly different usage context.
Let’s apply the Double Usage Assessment methodology to the
application architecture. I prepared another showcase project
for us to examine in addition to the cellular automata program,
and both share the same architecture. We now have two
projects with similar type-level basis, so we can compare them
and cross-validate the decisions. I’m sure you’ll like this new
application because it’s a Turing Machine implementation with a
type-level eDSL for rules, and the Turing Machine is an
emergent system, too.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 145
5.1.3 Two applications, same architecture
Our two applications – Celluar Autotama and Turing Machine –
belong to the same class when the business logic looks like
rules that operate with some data. Rules are pure algorithms
conceptually and are finely representable with an eDSL, either
value-level or type-level. Such systems are primarily
CPU-bound, not IO-bound, meaning that, in the simplest case,
there can be no interaction with the outside world while
evaluating the rules. Whole cellular worlds can evolve without
being even printed, and massive amounts of data can flow
through the Turing Machine without input-output in the middle
of the process.
Figure 5.3 Turing Machine
A scientifically defined Turing machine operates with an infinite
tape – a requirement unfeasible for actual implementations.
Being slightly less than infinity and to be just huge is not much
better for tapes. Operational memory might become a
bottleneck, and we’ll get a business requirement to partition the
data and use slow but unlimited external storage as a buffer or
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 146
a cache. This new requirement affects both the application
architecture and the solution architecture. Designing such a
product sounds interesting, but we’ll intentionally forego even
the weaker requirement of supporting gigabytes-long tapes.
Short tapes are more than enough for demonstration purposes,
so we don’t have to complicate the system.
You’ll find the Turing Machine application in this folder of the
book repository:
./First-Edition/BookSamples/demo-apps/turing-machine
What can it do? It has a simple interactive shell with the
following possibilities:
▪ load hardcoded rules and rules from files;
▪ load hardcoded tapes and tapes from files;
▪ create a new tape and its contents;
▪ run the machine with a rule and a tape;
▪ print a tape;
▪ select interfacing mechanism: type class or free monad.
A binary increment rule is shipped with the application.
Applying it on the tape “>1<0011011” will produce the
following tape:
⧫>1<0011100⧫
Here, the head position symbols >< do not exist on the tape
itself, it’s just a printing convention to point to the current
symbol. In turn, the blank cell symbol ⧫ does have its direct
representation on the tape and occupies a whole cell.
The rule eDSL is constructed with the idea of
self-descriptiveness and simplicity – as much as we can achieve
this on the type level. A fraction of the binary increment rule is
presented in listing 5.1:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 147
Listing 5.1 Binary Increment rule (fragment)
type BinaryIncrement =
'Rule
@TypeLevel #A
"Binary Increment"
1 #B
'[ 'State 1 "Start" #C
'[ 'Match "0" 'Skip 'R 2 #D
, 'Match "1" 'Skip 'R 2
, 'FailWith "Rule should start from a digit."
]
, 'State 2 "Find the Rightmost Digit"
'[ 'Match "0" 'Skip 'R 2
, 'Match "1" 'Skip 'R 2
, 'MatchBlank 'Skip 'L 3
]
, ...
]
#A Type Selector design pattern usage (soon in the book)
#B Initial state index
#C State description
#D Transition conditions
The rule describes states and transitions between them. Turing
Machine observes the current symbol on the tape, matches it
against possible cases in the corresponding state, possibly
writes a symbol into the current cell, and then moves to the
next state.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 148
Figure 5.4 Binary Increment rule
5.2 Application structure
Let’s now highlight specific aspects of application architecture:
layers, project organization, components architecture, and
briefly learn how to create console programs with
command-line interactions. This is not about type-level design,
but it’s about displacing type-level code properly.
5.2.1 Layered architecture
Both applications have the same big blocks that we can relate
to particular architecture layers:
▪ eDSL for rules (domain model or domain services layer);
▪ data model for worlds (domain model);
▪ pre-defined type-level rules (assets);
▪ pre-defined value-level worlds (assets);
▪ interface to the rules evaluation machinery (services and
subsystems layer);
▪ a specific implementation of the rules evaluation
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 149
machinery (the runner) that suites the interface
(implementation layer);
▪ in-memory existential storage for domain-related data and
rules (application layer);
▪ serializable configuration model for externally defined
configs, rules and data (application layer).
▪ command-line application (application layer) that uses: the
storage to access user-defined data and rules; the
interface to trigger the runner with the rules and data;
configuration model to load data from files.
NOTE You can find a full list of architecture layers and their
descriptions in my Functional Design and Architecture book
(Second Edition at Manning Publications), Chapter 2, “The
basics of Functional Declarative Design.”
We can now put these blocks into an informal architecture
diagram, first for the cellular application. See figure 5.5:
Figure 5.5 Cellular Automata application architecture
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 150
The applications share this high-level architecture, but there are
some differences in the design of components.
▪ The Cellular Automata application has an integrity
validation mechanism, and the Turing Machine doesn’t.
▪ The Turing Machine application allows one to select an
interfacing mechanism for comparison. This mechanism
can be a type class interface or a Free monad interface.
The differences will be discussed later in the chapter.
▪ The domain model of the Turing Machine application is
powered by the Higher-Kinded Data (HDK) and Granular
Type Selector design patterns. These patterns allow for
both static type-level and dynamic value-level rules. A
“materialization” mechanism transforms static type-level
rules into dynamic ones. (See the next chapter to explore
these design patterns.)
▪ The Turing Machine app's existential storage supports
static and dynamic rules.
▪ The runner interface also has some dissimilarities. In
particular, it uses the Dynamic Payload design pattern to
enable dynamic rules, and two corresponding runners
implement this mechanism. (We’ll investigate the pattern
in the next chapter.)
This gives us another architecture diagram that is quite similar
but still distinct:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 151
Figure 5.6 Turing Machine application architecture
The shown application skeleton works best for similar
applications where interpretable (possibly type-level)
domain-specific languages play the leading role. While a simple
command-line tool, it doesn’t require more involving
architectures such as FPR-based (FRP stands for “Functional
Reactive Programming”), events-based, or something else. Still,
even for this simple architecture, we’ve divided the code into
layers, in particular, the type-level domain layer (the rules
eDSL) and mixed-level implementation layer (interpreters).
Doing so gave us a clear separation of concerns and a finely
defined boundary between the layers. At least, this was our
intent. From the picture, we understand the responsibilities of
the layers and their relations. However, diagrams are not code.
(Let's not galvanize the failed Frankenstein's monster called
CASE-tools for automatic transformation of UML diagrams into
code (CASE stands for Computer-Aided Software Engineering)).
Diagrams represent the idealistic intent that may or may not
reflect the state of affairs. The code and the structure of the
project should also confirm that everything is done right. It’s
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 152
pretty easy to have an idealistic world and, simultaneously, a
bad implementation that ruins all the good ideas and intentions.
5.2.2 Project structure
The first look at the project structure should hint at where to
find a certain code. We have a physical carrier (files, folders)
and a logical carrier (namespaces, modules, packages) of
structure. Depending on the language and programming
culture, logical meaning may be reflected in physical carriers,
and usually, it is. Here, I’ll assume that files and folders should
follow the hierarchy of namespaces and modules, as in Haskell.
The top namespace should be a general project name or
codename. This helps to keep many projects finely separated
from each other, and might be useful for multi-project
repositories (for example, this book’s repo).
These are my top folders:
src/Cellular
src/Turing
Architectural layers should be easily distinguishable and
well-separated from each other; however, namespaces are not
needed for all layers. From my experience, some layers have a
higher priority than others, and some can be unified under a
single namespace.
The table below describes general namespaces for both
applications.
Namespace Layer Description
Cellular.App Application Everything about
Turing.App functioning of the
application: application
state types, console
commands, parsers,
config types, application
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 153
state machine etc.
Cellular.Assets Application, Pre-defined and
Turing.Assets business logic hardcoded bits
Cellular.Language Domain Core eDSLs, types and
Turing.Machine model, domain models of a domain
.Language services
Cellular Implementatio Runners and
.Implementation n interpreters of
Turing.Machine embedded
.Implementation
domain-specific
languages
Turing.Machine Implementatio Specific to the Turing
.Interface n Machine. Interfaces for
the runner
implementation to
provide a choice
between type class and
Free monad
My applications are simple, so there is not much to discuss,
except maybe a rule of thumb that I recommend strictly
following:
Call your structural items mnemonically. Use domain names,
meaning, purpose, layer names, and such.
Don’t call your structural items abstractly. Don’t use coding
techniques, language features, or abstract concepts.
I’ve seen some applications in Haskell with modules named
after mathematical concepts (Functor, Category, Identity,
Monad, etc.), while the internals were about some
domain-related entity that eventually had this concept as a
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 154
property. Navigating the project was difficult, and the overall
feeling was: “Oh, that’s obscure.”
For example, MyProgram.Monad is a bad name for the
application code despite the fact that the main function from it
operates inside the IO monad. The module should be called
MyProgram.Application.
Or imagine you see MyProgram.Language.Coyoneda and
have no idea what this module is about. You open it and see a
parametrized domain type Board that has some Coyoneda
concept as a property. From the structure, you could not even
guess that you have this domain notion. Why should this
important information be hidden and some implementation
details be revealed?
This is clearly a malpractice that increases accidental
complexity and hurts discoverability. We see something
auxiliary, something secondary to the business task, maybe
something the author of the code likes a lot. Enthusiasm is
good, but we’re not in a classroom; we should be pragmatic.
We should communicate the meaning, not our smartness.
This rule, however, may not work for abstract libraries, or better
say, it works, but the meaning is exactly the concepts or
mathematical properties, so putting them into file names
becomes valid. You can open any Haskell library about data
transformations (for example, lens) and see a lot of such
abstract stuff in the file names:
Control.Lens.Profunctor
Control.Lens.Fold
...
I also have an exclusion in the Turing Machine project. There
are two modules called after a technique and a language
feature:
Turing.Machine.Interface.TypeClass
Turing.Machine.Interface.FreeMonad
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 155
There is a reason, though: it’s a showcase project, and I want
to demonstrate how well a type class and a Free monad work
as an interface-like abstraction, so it’s not a mistake even. It’s
pedagogy. I’ll explain soon.
5.2.3 Organizing type-level code
Interestingly, we have two sorts of data in each application:
Behavioral model of domain logic that we deliberately decided
to make type-level: automata and Turing machine rules.
Additionally, both applications support a value-level dynamic
behavior model so that you can load the rules from files.
Operational data that can be of arbitrary sizes and will mostly
be provided at runtime: Board type for cellular app and Tape
type for Turing Machine.
Why not have operational data at the type level? Cellular worlds
and tapes, all done as types – this would allow me to evaluate
both behavior models at compile time. I foresee the
possibilities: if only worlds with certain properties are wanted
(for example, two gliders should not get placed into proximity),
a type-level integrity mechanism would permit this. In this
case, we’ll need a type-level world description. This might work,
although the more I think about it, the more I get scared by the
complexity of the code expected. Frankly, I think old good tests
would work better. But only if we have such a business
requirement.
The two models – operational data and behavior logic – belong
to the domain model. I prefer to put it into the Language
namespace:
Cellular.Language.Board #A
Cellular.Language.Algorithm #B
Cellular.Language.Automaton #C
Cellular.Language.Integrity #D
Turing.Machine.Language.Tape #E
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 156
Turing.Machine.Language.Rule #F
#A Board type for data – no type-level code
#B Cellular algorithm model, type-level ADT
#C Cellular rule description, type-level ADT
#D Type-level integrity checking and validators
#E Tape type for data – no type-level code
I also create a top exporting module that only groups
submodules and does not provide any logic:
Cellular.Language #A
Turing.Machine.Language #B
#A Reexports everything from Cellular.Language.XYZ modules
#B Reexports everything from Turing.Machine.Language.XYZ modules
Most of the type-level code is concentrated here, on the domain
model layer. The reach of these domain notions would be better
limited to certain places: type-class-based interpretation,
assets, application’s existential storage. We really want to avoid
spreading type-levelness too much because each occurrence of,
for example, CellWorld (rule :: CustomRule) makes this
code highly coupled with the domain model. Even worse, we
bring too much knowledge about the fact that our domain
model is type-level and urge the client code to rely on
sophisticated features additionally.
Ideally, only the implementation layer should be allowed to
reference type-level domain notions. The application layer
should depend on the implementation layer but should be
unaware of the type-levelness. For this to happen, the
implementation layer should expose an interface that hides the
domain model from the application layer. See the figure below:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 157
Figure 5.7 Perfect dependency between layers
Unfortunately, my code is not perfect in this sense. Take a look
at this function from the Application module (Cellular
Automaton project):
f' :: forall rule
. IAutomaton () rule
=> WorldIndex
-> Generation
-> CellWorld rule
-> IO ()
There is indeed the interface, IAutomation, which is a type
class, but we still see CellWorld and rule type parameters
here. Generalized ones, yes, but it’s nevertheless the
dependency. In essence, these domain-related type-level
details leak through the interface because type classes are not
real interface-like abstractions. They don’t do encapsulation and
information hiding, especially when working with type-level
code.
I know this topic needs more elaboration. I’ll return to it in the
next chapter and will explain why the naming is so Java-style
(the I prefix!), why the interface has two type parameters, why
one of them is the unit type (), and how to really make things
separate with proper interface-like abstractions.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 158
As a final note here, I’ve seen several sinful Haskell codebases
that neglect the idea of keeping type-level code in the pen. In
particular, almost every Cardano project (Cardano is a
blockchain solely written in Haskell) has this violation to the
degree that it’s no longer a mistake but rather a coding practice
(I would avoid saying “design practice”). Here are two functions
from different projects:
Listing 5.2 Cardano-related code sample
-- cardano-ledger
bodyShelleyTxL :: EraTx era
=> Lens' (ShelleyTx era) (TxBody era)
-- cardano-wallet
manageRewardBalance
:: forall n block
. Tracer IO WalletWorkerLog
-> NetworkLayer IO block
-> DBLayer IO (SeqState n ShelleyKey)
-> IO ()
Take a look at
(ShelleyTx era), (TxBody era),
(SeqState n ShelleyKey). These things slip through all the
function hierarchies, occur in sudden places, and force related
and unrelated data types to have extra type parameters or to
be existential data types. This certainly makes the code
extremely complex, rigid, and very unordered. There is no clear
separation of layers; there are no good interfaces between
subsystems; in fact, different levels of abstraction can easily
occur in the same code. It’s there backed into the projects from
the start and can’t be fixed. The problem comes right from the
upper level: from the application architecture, from lack of it,
and therefore can’t be fixed without a complete rewrite.
5.2.4 Application layer
In this section, I want to give you an overview of the
application layer – the things that constitute the application
itself. The structure of this layer and the approaches taken are
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 159
all described in my Functional Design and Architecture book, so
the overview will be brief. Let’s investigate the Turing Machine
app here.
First, it’s an interactive console program. I decided to ignore
advanced approaches for organizing the console interaction and
did it simply. There are commands all denoted with this type:
Listing 5.3 Commands
data Command
= Help
| Quit
| Tapes #A
| NewTape String #B
| LoadTape String #C
| LoadPredefTapes #D
| Rules #E
| LoadPredefRules #F
| LoadRule String #G
| Materialize #H
| Run Int Int #I
| PrintTape Int #J
deriving (Show, Eq, Read, Ord)
#A List all tapes
#B Create a tape from a string
#C Load a tape from a file
#D Load predefined hardcoded tapes
#E List all rules
#F Load predefined hardcoded rules
#G Load a dynamic rule from a file
#H Make a dynamic rule from a static type-level one
#I Run a rule with a tape
#J Print a tape
I read a string from the console and parse it into the Command
type, if possible. Then, I interpret it and do the stuff:
runCommand
:: InterfacingSwitch #A
-> Cmds.Command
-> AppState #B
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 160
-> IO AppAction
runCommand _ Cmds.Help _ = ...
runCommand _ Cmds.Quit _ = ...
runCommand _ Cmds.Tapes st = ...
...
#A Switch to choose between type class interface and Free monad
interface
#B Application state
At the start, you can pass one of the two command-line
arguments to pick either type class-based interface or a Free
monad one. That’s why the code contains
InterfacingSwitch.
data InterfacingSwitch = TypeClass | FreeMonad
These are the parameters:
$ stack exec turing-machine "type-class"
$ stack exec turing-machine "free-monad"
Application state is mutable variables to store tapes and rules:
data AppState = AppState
{ asRulesRef :: IORef Rules
, asTapesRef :: IORef Tapes
}
The Rules type is a dictionary of RuleImpl, which is an
encapsulated rule description, either existentified or dynamic.
I’ll postpone posting its details until we talk about interfaces in
the next chapter.
type Rules = Map RuleIndex (RuleImpl, String)
Another component of the application layer is what I call a
“package” here. It’s a configuration model that includes
serializable rule and tape data types. C# and Java developers
would call these Data Transfer Objects (DTOs). These types are
only needed to load packages of rule+tape from files. This
model is separate, value-level (ADT-based), and should be
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 161
converted to the value-level domain model in which form the
implementation would work with it. A small fraction of the
model is presented below:
module Turing.App.Package.Rule where
data Rule = Rule
{ name :: String
, initState :: Int
, states :: [State]
}
deriving (Read, Show, Eq, Ord)
One interesting feature of this showcase application is that we
can materialize static type-level domain model into a dynamic
value-level model, and not only that, but also reuse the same
types. This, however, requires some extra type-level magic
because not all types suit both models. This is a domain type
for a Turing Machine rule. Notice the lvl type parameter and
its occurrences in the field types:
data CustomRule (lvl :: Level)
= Rule
{ crRuleName :: StringType lvl
, crInitState :: IntType lvl
, crStates :: [CustomState lvl]
}
| DynamicRuleTag
You see here the usage of two design patterns simultaneously:
Higher-Kinded Data (HKD) and Granular Type Selector. This
parametrization certainly can impact the application layer, and
we’ll consider all the circumstances of these approaches soon in
the next chapter.
5.3 Summary
▪ Software architecture is a rich and complex subject. When
designing code, application architecture should be
considered together with solution architecture.
▪ Separating the application into logical layers, such as the
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 162
domain layer, application layer, persistence, and others
mitigates the bad impact of type-level code on accidental
complexity.
▪ Representing application architecture in diagrams
leverages its clarity.
▪ Such practices as Double Usage Assessment, Dogfooding,
and Ruber Duck Debugging help to look at the same object
from a different angle. Quite often, this reveals interesting
yet hidden insights.
▪ Interfaces should help to minimize the coupling between
layers and subsystems.
▪ Several design patterns and approaches can be used for
joining type-level and regular code: valuefication,
existentification, Granular Type Selector, Higher-Kinded
Data, and others.
▪ It’s better to keep type-level code organized and focused
so that type-level bits and complexity don’t infiltrate the
code in arbitrary places.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 163
Chapter 6
Components design
This chapter covers
▪ What are static and dynamic domain models
▪ How to reuse the same ADT for both models
▪ What are the takeaways of type classes and Free monads
as interfaces
Much of what we’ve achieved in the previous chapters relates to
domain modeling directly. We took some domain notions,
investigated, clarified the business requirements, and
embedded them into a bigger domain model. We even created
several domain-specific languages as much type-level as
possible. This certainly couldn't be possible without various
language features and design ideas we learned during domain
modeling. Let me remind you of some of these techniques:
▪ promoting types to be types of types, i.e., kinds;
▪ lifting values of ADTs from the mixed level to the pure type
level to be types;
▪ expressing domain meanings with type-level strings and
integers;
▪ using proxy types to carry pure types without creating
values for them;
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 164
▪ using parameters of ADTs as fields on the type level;
▪ using phantom types for pinning specific versions of types
to make them completely distinct;
▪ making invalid states unrepresentable with type-level
validation algorithms.
We also found several approaches to connect value level, mixed
level, and type level:
▪ existentification;
▪ valuefication;
▪ type-level interpretation with type classes.
This set of tools is complete for us to create useful programs,
and we did. According to the principle we stated in the
beginning, it’s better to use fewer powerful concepts to solve
most tasks, even if this implies more labor, more boilerplate,
and less syntactic variability of the code. This principle is called
Dumb but uniform, and its goal is to make our code simpler and
easier to maintain. If everything is made uniform, no surprises
are likely to occur, and not much mental burden is expected.
This principle also weirdly resembles the idea of emergence: a
small number of pieces suddenly emerge into a rich behavior,
which, in our case, means that with the tools we obtained,
domains of any difficulty can encode.
Let me show you several other useful ideas in type-level
domain modeling and application construction.
6.1 Static and dynamic domain models
Domain modeling with functions and values or advanced types
is a good start to approaching the business task. It might also
be the most interesting part of software development because it
allows us to apply creativity and explore various designs. This is
especially true for domain-specific languages – those that carry
the behavior of the domain – but creativity can also be limitedly
applied to the design of domain notions. However, we should
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 165
remember our goals and stay pragmatic in this endeavor,
especially when designing type-level models. We should always
remember that accidental complexity is a lurky beast that can
suddenly eat us without mercy.
You might be interested in consulting with the domain model of
the Turing Machine application while reading the material:
Figure 6.1 Turing Machine domain model and eDSL
6.1.1 Separate type-level and value-level models
Both Cellular Automaton and Turing Machine applications
support static type-level and dynamic value-level domain
models. This wouldn’t be a headline if I added separate ADT
hierarchies for the models. What’s so special about having this:
Listing 6.1 Separate static and dynamic models
module MyStaticModel where
data StaticRule = StaticRule #A
{ srRuleName :: Symbol #B
, srInitState :: Nat
, srStates :: [StaticState]
}
module MyDynamicModel where
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 166
data DynamicRule = DynamicRule #C
{ drRuleName :: String #D
, drInitState :: Int
, drStates :: [DynamicState]
}
#A ADT for static type-level model
#B Type-level field only
#C ADT for dynamic value-level model
#D Value-level field only
You can’t create a regular value of StaticRule because it
contains a Symbol field, which only appears on the type level.
Inversely, the String type will only work for the value level.
myName :: Symbol -- doesn’t compile
myName = "Alexander"
But even if the Symbol type worked (and this might suddenly
change in Haskell), there might be a desire to make static and
dynamic models different. For example, in StaticRule, we
would only allow for two or more states so the compiler could
preserve this invariant:
srStates :: List2 StaticState
However, we decided to keep DynamicRule simple. No
developers intend to program it, and its only purpose is to
parse rules from external sources, so maintaining two parallel
packs of domain types is a viable solution. Let me strictly
recommend this if your domain models are small. But what if
you really want to reuse one set of types for everything? This is
where we need a couple of new design patterns.
6.1.2 Granular Type Selector design pattern
Neither of the models in my applications is huge – just a couple
of types here and there. Nevertheless, I have reused one ADT
hierarchy model for static and dynamic models. They are mostly
the same, but still different because I can’t legally stick to only
String or Symbol – I have to pick one. This means I need to
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 167
alter the field type depending on what level it addresses. The
picture below schematically describes what I do:
Figure 6.2 Type Selector design pattern usage
You can notice here several things:
▪ The CustomRule type is parametrized with the lvl; type
parameter of a kind Level;
▪ it’s called a HKD template (HKD stands for Higher-Kinded
Data);
▪ the Type Selector design pattern alters the field somehow.
Take a look at the CustomRule type now. Ignore the
DynamicRuleTag variant for now and pay your attention to the
journey of the lvl type parameter through the fields:
data CustomRule (lvl :: Level)
= Rule
{ crRuleName :: StringType lvl
, crInitState :: IntType lvl
, crStates :: [CustomState lvl]
}
| DynamicRuleTag
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 168
When we specify lvl, we end up with concrete types instead of
StringType lvl and IntType lvl. Instantiating a static
type-level rule now looks like this:
type EmptyRule = 'Rule @TypeLevel "Do nothing" 1 '[]
I pass my selector TypeLevel into the template CustomRule
to get a fully capable type-level value. The Level ADT looks
pretty simple because the essence of the Granular Type
Selector pattern is in the type families:
Listing 6.2 Granular Type Selector design pattern
data Level
= TypeLevel
| ValueLevel
type family StringType (lvl :: Level) where
StringType 'TypeLevel = Symbol #A
StringType 'ValueLevel = String #B
type family IntType (lvl :: Level) where
IntType 'TypeLevel = Nat #C
IntType 'ValueLevel = Int
#A Pick Symbol if lvl is TypeLevel
#B Pick String if lvl is ValueLevel
#C Same for the integer type
Of course, there should be many type families for choosing
between various types. I also have a char type selector in my
library:
type family CharType (lvl :: Level) where
CharType 'TypeLevel = Symbol
CharType 'ValueLevel = Char
If needed, you can roll your own, not just for regular types. For
example, I could alter the crStates field so that on the type
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 169
level, it is List2 guarantees, and on the value level, it is a
simple list:
type family StateType (lvl :: Level) where
StateType 'TypeLevel = List2 (CustomState 'TypeLevel)
StateType 'ValueLevel = [CustomState 'ValueLevel]
data CustomRule (lvl :: Level) = Rule
{ crStates :: StateType lvl
}
One of the tradeoffs of the solution is that we now have some
domain-agnostic infrastructure in all the ADTs for which it
makes sense. In the case of the Turing Machine model, all the
types carry it:
data CustomRule (lvl :: Level) = ...
data CustomState (lvl :: Level) = ...
data CustomCondition (lvl :: Level) = ...
data CustomWriteAction (lvl :: Level) = ...
data CustomMoveHeadAction (lvl :: Level) = ...
Fortunately, Haskell’s compiler deduces this type parameter for
the nested ADTs when specified for the top value, 'Rule. It is,
therefore, optional for 'State, 'Match, and 'FailWith:
type BinaryIncrement = 'Rule @TypeLevel "Binary Increment" 1
'[ 'State 1 "Start"
'[ 'Match "0" 'Skip 'R 2
, 'Match "1" 'Skip 'R 2
, 'FailWith "Rule should start from a digit."
]
...
]
Notice the usage of @TypeLevel here, a type of the Level
kind. It precedes the “fields” in the 'Rule type. And this is how
a similar rule would look like on the value level:
binaryIncrement :: CustomRule 'ValueLevel
binaryIncrement = Rule "Binary Increment" 1
[ State 1 "Start"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 170
[ Match '0' Skip R 2
, Match '1' Skip R 2
, FailWith "Rule should start from a digit."
]
...
]
Now, it’s 'ValueLevel, and it specifies the CustomRule type.
The next two figures should help you to navigate between
promoted and regular ADTs and their fields:
Figure 6.3 Instantiating a type-level domain model
Figure 6.4 Instantiating a value-level domain model
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 171
I should admit that sometimes, type inference breaks, and we
must pollute the definitions with extra tags. In other languages,
developer experience might be drastically different, especially if
there is no type families feature or analog.
TIP In other languages, altering types of fields in compile
time is only sometimes possible or might require different
metaprogramming approaches. See Part IV: Rosetta Stone
for more info.
I called this design pattern “granular” because it works for each
ADT field individually. The Monolithic Type Selector pattern will
alter the fields altogether. We’ll learn it and the HKD pattern
when the need arises.
6.1.3 Interpretation of static and dynamic models
Although sharing the same ADT construction for static and
dynamic models is possible, they are too different to have a
single implementation machinery. We need to address each
with special tools.
Type-level interpretation can be done with type classes, as we
have done before. Nothing has changed much, but you’ll need
to carry the concrete lvl type parameter here and there. For
example, these are my internal type classes for running a rule
and its parts:
class RuleRunner (rule :: CustomRule 'TypeLevel) where
runRule :: Proxy rule -> Tape -> Either String Tape
runRuleName :: Proxy rule -> String
class StatesRunner (states :: [CustomState 'TypeLevel]) where
runStates :: Proxy states -> CurrentStateIdx -> Tape -> Result
It's a bit more of a boilerplate, but nothing new here. Just
traverse the types and run actions for them, or construct
something that will be evaluated later.
The boilerplate can be somewhat reduced with type aliases:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 172
type CustomRuleTL = CustomRule 'TypeLevel
type CustomRuleVL = CustomRule 'ValueLevel
type CustomStateTL = CustomState 'TypeLevel
type CustomStateVL = CustomState 'ValueLevel
This is shorter and probably less confusing (or more for those
who are not familiar with this convention):
class RuleRunner (rule :: CustomRuleTL) where ...
class StatesRunner (states :: [CustomStateTL]) where ...
Interpretation of the dynamic model with bare function will also
benefit from the aliases:
runDynamicRule :: CustomRuleVL -> Tape -> Either String Tape
runDynamicStates :: [CustomStateVL] -> Int -> Tape -> Result
That’s actually it. In both cases, the runner accepts a rule in
some form and a tape (as a value), does its magic, and then
returns either a new tape or an error. I literally have two
implementations of runners in the Turing Machine project.
There might be a way to call dynamic interpretation functions
from static type-class-based interpretation, and you might want
to program your logic like so to avoid functionality duplication.
But maybe another option would be more interesting. We don’t
invoke dynamic runners from static ones, but we convert the
static model into a dynamic one and then use dynamic runners
for it. I call this approach “static materialization”.
6.1.4 Static materialization
The turing-machine project provides a pre-hardcoded rule,
“Binary Increment,” and four pre-hardcoded instances. The
instances can be loaded to play with (see listing 6.3). Some are
static type-level, and some were once type-level but
materialized into dynamic rules. In particular:
▪ static with type class
▪ static with free monad
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 173
▪ materialized/dynamic with type class
▪ materialized/dynamic with free monad
Listing 6.3 Turing Machine session: loading predefined rules
$ stack exec turing-machine
Type a command: LoadPredefRules
Rule loaded: [0] (static, type class) Binary Increment
Rule loaded: [1] (mat/dynamic, type class) Binary Increment
Rule loaded: [2] (static, free monad) Binary Increment
Rule loaded: [3] (mat/dynamic, free monad) Binary Increment
The rule instance number 0 can also be materialized with the
corresponding command. See the next listing:
Listing 6.4 Turing Machine session: loading predefined rules
Type a command: Materialize
Materialized: [0] Binary Increment
Type a command: Rules
Supported rules ([idx] name):
[0] (mat/dynamic, type class) Binary Increment
[1] (mat/dynamic, type class) Binary Increment
[2] (static, free monad) Binary Increment
[3] (mat/dynamic, free monad) Binary Increment
Although rule number 2 is initially static, it can’t be materialized
because it’s encapsulated with the Free monad, which makes it
opaque. We’ll get to this question in the last part of this
chapter.
Static materialization is not too tricky, although I found several
interesting design takeaways I want to share.
There will be two special type classes for the materialization:
one for an arbitrary type-level model and another for type-level
lists. Let’s explore them closely:
Listing 6.5 Static materializer
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 174
{-# LANGUAGE FunctionalDependencies #-}
class Materialize payload (a :: t 'TypeLevel) b
| payload a -> b where
mat :: payload -> Proxy a -> b
class MaterializeList payload list b
| payload list -> b where
matList :: payload -> Proxy list -> b
The code is a bit busy. This is the legend:
payload - dynamic payload - extra info that might be
required during the materialization process
a - source static type
b - target dynamic type
t - kind of the source type
t 'TypeLevel - requirement it to operate on the type level
| ... -> ... - functional dependency
| payload a -> ... - two defining types for which functional
dependency should work
| ... -> b - result type that should be automatically
deduced from the defining types of functional
dependency
Both type classes take three parameters: a payload type for
extra info if needed, a source type (a and list), and a target
type b. When instantiated, a type class with three type
variables complicates the type inference much, and sometimes,
it’s difficult for the compiler to understand what b type we want
to get. Without Haskell’s feature FunctionalDependencies,
the compiler will think that all source types can freely result in
any target types with no limits. This might confuse the compiler
in various code places. To help him, we have to enable
FunctionalDependencies. This row: payload a -> b
means that “Whenever you see a specific type payload and a
specific type a, the only type that relates to them will be
specific type b. Don’t try to deduce it; just eat what you were
given.” “Specific” here means not type parameters but concrete
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 175
types. Let’s see, for example, how this works for the
CustomWriteAction type:
data CustomWriteAction (lvl :: Level)
= Write
{ cwaSymbol :: CharType lvl
}
| WriteBlank
| WriteMatched
| Skip
We have four promoted types to convert: 'Write,
'WriteBlank, 'WriteMatched, Skip. Each of them will have
an instance of the Materialize type class like this one:
instance
Materialize () 'WriteMatched CustomWriteActionVL where
mat _ _ = WriteMatched
The payload is the unit type (), telling us we need nothing
extra. There is the 'WriteMatched type, which is the second
source type. When the compiler sees both () and
'WriteMatched, it knows immediately that the result type
should be CustomWriteActionVL because the instance tied
them with functional dependencies. The result type now
depends on the two source types. No other target types can be
selected, but if you need to, you can specify another pair of
source types to result in CustomWriteActionVL. This is
exactly what we need for the other three instances. One more
of them:
instance
Materialize () 'WriteBlank CustomWriteActionVL where
mat _ _ = WriteBlank
We can now invoke one materializer from another with abstract
type parameters for the source types:
instance
( Materialize () writeAct CustomWriteActionVL
, Materialize () moveAct CustomMoveHeadActionVL
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 176
, KnownNat nextStateIdx
) =>
Materialize ()
('MatchBlank writeAct moveAct nextStateIdx)
CustomConditionVL
where
mat _ _ = ...
This will work fine.
Another difficulty is type-level lists, which are treated
differently. I won’t present much detail here; I will just give you
the pattern to follow.
The rule has many states. Materializing it will require
materializing the states with the help of an additional empty
parametrized ADT. I generally call these ADTs “list tags”:
data StatesTag ss
Now, the states list materializer works not on the list itself but
on the list wrapped into this tag. For example, the base case of
the recursion should process the empty list:
instance
MaterializeList () (StatesTag '[]) [CustomStateVL] where
matList _ _ = []
In this form, the list materializer should be invoked from the
parent one. Check out listing 6.6 (I simplified the code to make
a clearer point):
Listing 6.6 Static materializer of a Rule
instance
( MaterializeList () (StatesTag states) [CustomStateVL]
) =>
Materialize () ('Rule name idx states) CustomRuleVL where
mat _ _ = let
states = matList () (Proxy @(StatesTag states)) #A
in Rule "" 1 states
#A Packing the list type into the tag
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 177
We have to pack the actual list into the tag, and we do. If we
need to deconstruct the list, we also repack it, like here (take
notice of the s and ss usage):
instance
( Materialize () s CustomStateVL
, MaterializeList () (StatesTag ss) [CustomStateVL]
) =>
MaterializeList () (StatesTag (s ': ss)) [CustomStateVL] where
matList _ _ =
mat () (Proxy @s) #A
: matList () (Proxy @(StatesTag ss)) #B
#A Materialize a single state
#B Materialize a list of states
This is essentially the trick, and nothing more to be said here.
Once you run it, you’ll get a dynamic model suitable for further
evaluation. However, static materialization can have even more
exciting developments, which will be a topic for the next
chapter.
6.1.5 Data Transfer Objects and serialization
Let me tell you how I fought the temptation to reuse the same
ADT for one more place: config files and external serialization
format for the rule domain model. It was a hard battle, and I
almost lost! The serialization code was in front of my gates,
ready to invade my Turing.Machine.Language modules, but
I made the last effort and managed to win. There is now a
simple, separate model suitable for Haskell’s Read/Show
serialization. This is one of those types:
module Turing.App.Package.Rule where
data Rule = Rule
{ name :: String
, initState :: Int
, states :: [State]
}
deriving (Read, Show, Eq, Ord)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 178
Just to be clear, don’t use Read/Show for serialization! It is the
wrong way and should only be used for debugging. You might
want the Aeson library or other actual serialization libraries for
this. I did this for the demo purposes only.
This model should be used only for external communications.
This idea resembles the mainstream languages' DTOs (Data
Transfer Objects). Sometimes, we need several distinct DTO
type sets because each can serve specific needs. For example,
some communication channels need a binary format of the
fields, others work with base64 strings, and one more set of
types cannot use dictionaries. We’d like to avoid putting
everything into a single model, although this means more labor
in maintenance and conversion.
The HKD and Type Selector design patterns can help here to
some extent, but the burden of maintaining multiple packs of
similar types is well known. This is our development reality; we
can’t make everything perfect. Pragmatism is rooted in
understanding when we should stop polishing, abstracting, and
generalizing and should not complain that we’re not in a pure
romantic utopia. We will never be there.
6.2 Two functional interfaces
A curious reader can take a look at Haskell’s generic idioms:
Foldable, Monoid, Semigroup, Functor, Applicative, Bifunctor,
Category, and many, many others. All of these idioms are type
classes that express some generic mathematical property that
can be found in various data structures. Once a data structure
suites for some idiom, it can be used with generic functions that
don’t know the data structure but know the property of the
idiom.
For example, Functor. It traverses a data structure (maps
over it) and alters its items without changing its shape.
class Functor f where
fmap :: (a -> b) -> f a -> f b
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 179
It works for many generic data structures, in particular, for lists.
A list is a functor that may change its content type after
mapping but will keep the same number of items.
instance Functor [] where #A
fmap f x:xs = f x : fmap f xs #B
fmap _ [] = []
#A Functor for the generic list type
#B Alter each item
The named idioms are genericity-like abstractions. They are
truly abstract and domain-agnostic. Most of them work over
generic data structures such as List, Map, and Array, and
some might be defined for a broader set of types.
NOTE Learn from Rosetta Stone about similar
genericity-like mechanisms in other languages such as
C++, Rust, and Scala.
Genericity-like abstractions are where type classes work best,
which we didn’t discuss in this book. And we won’t; although
designing such generic mechanisms has some relation to
type-level programming, it’s primarily out of our scope.
Instead, we’ll compare the type classes as an interfacing
mechanism to the Free monad. Although these topics relate to
Functional Declarative Design, I find it necessary to touch them
anyway to complete the discussion on application construction.
6.2.1 Properties of a true interfacing mechanism
All interfacing mechanisms should support several crucial
properties:
▪ Abstraction: An interface abstracts behavior, specifying
methods without their implementation details.
▪ Contractual Obligation: Implementing an interface
creates a contractual obligation to provide the specified
methods, ensuring interchangeability among
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 180
implementations.
▪ Multiple Implementations: Interfaces allow multiple
implementations, supporting polymorphism by treating
different implementations uniformly.
▪ Separation of Concerns: Interfaces separate behavior
specification from implementation, enabling
implementations to vary while adhering to a common
contract.
▪ Encapsulation: Interfaces encapsulate the
implementation details, making them invisible and
preventing them from leaking outside into the client code.
▪ Interface Segregation: This principle advocates for
fine-grained, responsibility-specific interfaces, reducing
dependency on broad interfaces.
▪ Contract Evolution: Interfaces should evolve in a stable,
backward-compatible manner to avoid disrupting existing
implementations.
We’ll see that Abstraction, Multiple Implementations, and,
especially, Encapsulation are the properties the type class
mechanism violates due to its genericity-like essence.
6.2.2 Type class versus Free monad
In Haskell, type classes as interfaces became very popular
because true interfaces weren’t properly recognized, and the
crucial differences between the two kinds of abstractions were
mainly unknown. Due to a lack of solid knowledge, the
consequences of “untrue” interfaces to the codebases could not
be well-discussed.
I also adopted type class as an interface for the Cellular
Automaton application to demonstrate the possibilities. Under
other circumstances, I would choose a proper interface-like
abstraction like a Free monad. To show you why, I added both
into the Turing Machine application; this is when the Double
Usage Assessment practice occurs again: you can compare
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 181
them to see how each affects the accidental complexity of the
code.
Choose your fighter:
$ stack exec turing-machine "type-class"
$ stack exec turing-machine "free-monad"
Figure 6.5 shows the place of the interfaces and
implementations in the architecture:
Figure 6.5 Free monad and type class interfaces
This diagram displays a lot. Let me explain.
▪ Two interfaces are gates into the implementation
subsystem: Machine (a Free monad) and IMachine (a
type class). I decided to name them differently to avoid a
name clash. The I prefix works best for type classes as
interface-like abstractions and is unnecessary for Free
monadic ones.
▪ The application interacts with the implementation layer
through instances of these interfaces that we keep in the
storage.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 182
▪ There are two implementations of the Free monad
interface: Free monad interpreters.
▪ There are two implementations of the IMachine interface:
type class instances brought to the storage with
existentification.
▪ There are two actual implementations of the Rule eDSL:
runDynamicRule (a function) and RuleRunner (an
internal type class with its own instances).
Here is the updated table of extensibility mechanisms:
Figure 6.6 Extensibility approaches
We’ll ignore the underwater part of the iceberg and focus on the
interfaces and their integration into the storage. If you want to
know how this works in detail, you can always examine the
repository.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 183
6.2.3 Free monad interface
I don’t plan to explain a Free monad and how it works.
Functional Design and Architecture contain extensive material
on this matter. Alternatively, you can read my articles, and we’ll
proceed.
LINK Alexander Granin, Hierarchical Free Monads: The Most
Developed Approach In Haskell
https://github.com/graninas/hierarchical-free-monads-the-
most-developed-approach-in-haskell
LINK Alexander Granin, Functional Declarative Design: A
Comprehensive Methodology for Statically-Typed
Functional Programming Languages
https://github.com/graninas/functional-declarative-design-
methodology
This is the Free monad interface:
Listing 6.7 Static materializer of a Rule
data MachineMethod next where #A
Run #B
:: Tape #C
-> (Either String Tape -> next) #D
-> MachineMethod next
Name #E
:: (String -> next) #F
-> MachineMethod next
type Machine a = Free MachineMethod a #G
runFM :: Tape -> Machine (Either String Tape) #H
nameFM :: Machine String
#A Methods of the interface (algebra)
#B Method for running a rule with a tape
#C Input paramter: tape
#D Method result: either new tape or error
#E Method for getting the name of the rule
#F Method result: name
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 184
#G The Free monad interface itself
#H Convenient method helpers (“smart constructors”)
As you can see, there are two methods. Interestingly, neither of
them refers to CustomRule: static nor dynamic. Where does it
go?
The idea is to have an interpreter that accepts the rule and
encapsulates it deeply in its functions. The interpreter should be
spawned for each rule, getting its first-class instance, which
then goes into the storage. The client code gets the instance
from the storage when needed and uses the machine’s
methods. Because the rule is encapsulated in the instance, the
client code doesn’t see it but can interact with it anyway.
Take a look at the interpreter’s main function:
dynamicRuleInterpreter :: CustomRuleVL -> Machine a -> a
As you can see, it takes both the dynamic rule CustomRuleVL
and the Free monadic script Machine a, evaluates the rule,
and returns the result requested by the script. Encapsulation
happens when we partially apply the interpreter:
binaryIncrement :: CustomRuleVL
binaryIncrement = Rule "Binary Increment" 1 [ ... ]
ruleInstance :: Machine a -> a
ruleInstance = dynamicRuleInterpreter binaryIncrement
The storage keeps this value with the help of the RuleImpl
structure (storage here is simplified):
data RuleImpl where
FreeMonadRI
:: (forall a. Machine a -> a) #A
-> RuleImpl
storage :: RuleImpl
storage = FreeMonadRI ruleInstance #B
#A Encapsulated interpreter in the ADT field
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 185
#B Encapsulation
Finally, the client code can interact:
Listing 6.8 Interface usage
applyRuleToTape :: Tape -> RuleImpl -> IO ()
applyRuleToTape tape1 (FreeMonadRI ruleInterpreter) = do
let eTape2 = ruleInterpreter (runFM tape1) #A
case eTape2 of
Left err -> print ("Rule failed: " <> err)
Right tape2 -> print ("Rule succeeded. New tape: "
<> show tape2)
#A Run the Machine method with the interpreter
Notice that the rule is not mentioned here at all, so the code
stays clean from the type-level bits we introduced into the
domain model. It doesn’t even matter what rule it is: static or
dynamic. We can encapsulate both with interpreters. Another
one:
staticRuleInterpreter
:: RuleRunner rule
=> Proxy (rule :: CustomRuleTL) -> Machine a -> a
When partially applied, it suits the same FreeMonadRI storage,
and the client code from the listing 6.8 will remain the same.
This is how we’ve achieved uniformity and reduced the
accidental complexity of the code.
6.2.4 Type class interface and the Dynamic Payload design
pattern
Now I’ll show you the monster. This is the actual storage type
that is in the project:
Listing 6.9 Actual storage type
data RuleImpl where
TypeClassRI #A
:: (IMachine () (rule :: CustomRuleTL)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 186
) => Proxy rule -> RuleImpl
TypeClassDynRI #B
:: IMachine CustomRuleVL DynamicRule #C
=> CustomRuleVL -> RuleImpl
FreeMonadRI
:: (forall a. Machine a -> a)
-> RuleImpl
#A Existentified wrapper for static rules
#B Existentified wrapper for dynamic rules
#C Dynamic Payload design pattern utilized here
What issues do you see in the code? I see three issues:
▪ If we want to reuse the same type class for dynamic and
static models, the Dynamic Payload design pattern will be
required. We can certainly add a type class for the dynamic
model and not mix it, but having two interfaces for the
same thing is silly and contradicts the concept.
▪ Still, we have to use the type class differently for each
case. Keeping its usage uniform leads to two separate
existential wrappers and switch cases.
▪ The interface exposes the domain model as is. This couples
the client code and the model too much.
There is one more hidden issue (DynamicRuleTag) that I’ll
keep secret for a while.
The type class is simple:
class IMachine
payload #A
(rule :: CustomRule lvl) where #B
run :: payload -> Proxy rule -> Tape -> Either String Tape
name :: payload -> Proxy rule -> String
#A Arbitrary payload
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 187
#B Arbitrary rule
There are just two methods and some extra payload data type
that we can use for our needs. Instantiating the type class for
the static type-level rule is also simple. We just relay the
execution to the internal type classes we discussed earlier:
instance
( RuleRunner rule #A
, rule ~ 'Rule name idx ss #B
, KnownSymbol name
) =>
IMachine () rule where #C
run () = runRule
name () = runRuleName
#A Internal static runners
#B Declaring a type variable for rule
#C No extra payload used
It was nothing fancy, except I had to declare the rule type
variable instead of direct pattern-matching over the Rule type.
Please don’t ask why, or we risk getting lost in the GHC’s type
inference topic, which is an inappropriate discussion for this
book.
The instance for the dynamic rule is where things get crazier.
We can’t just instantiate for CustomRule 'ValueLevel:
instance
( rule ~ ???
) =>
IMachine () ??? where
We’re not processing a type this time, we should pass a runtime
value. Also, the type of kind CustomRule 'ValueLevel just
can’t be created because when we altered the string fields of
the model, they became String, – which is not available on
the type level. We should have a specific type of this kind for
the dynamic case. We obtain it from the special constructor:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 188
data CustomRule (lvl :: Level)
= Rule ...
| DynamicRuleTag
type DynamicRule = 'DynamicRuleTag @'ValueLevel
Now, when we have this DynamicRule type, and there is a
dynamic value of type (not kind) CustomRuleVL, we can
introduce the instance. This is where we need the payload
variable:
instance
IMachine CustomRuleVL DynamicRule where
run rule _ = runDynamicRule rule
name (Rule n _ _) _ = n
name DynamicRuleTag _ = error "Don’t create DynamicRuleTag."
That’s it. The instance descends to the existing implementation
and doesn’t do anything else. But notice how much we did to
support both models solely, and we haven’t even gotten the
best developer experience yet. Just wow!
NOTE As long as this payload variable is used for runtime
values, I will call it the Dynamic Payload design pattern.
You will see this once again in the advanced
materialization section in the next chapter.
The problems I mentioned here are real, and they occur every
time the type class feature is used as the interface-like
abstraction. It leaks by definition, it doesn’t satisfy the
Encapsulation property, and it doesn’t do information hiding. It
leaks because it lacks. And I didn’t even mention the inability of
type-class-like interfaces to return other type-class-like
interfaces due to some method! The thing that we can easily do
with OOP interfaces and Free monads.
public interface IWorker
{
string Work();
}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 189
public interface IFactory
{
IWorker CreateWorker(); #A
}
#B Good luck doing this with type classes!
This limitation alone makes type classes the wrong tool for
proper interfacing and extensibility. Type classes work nicely for
generic algorithmic tasks but not for encoding complex domain
behaviors. And no, it’s not about OOP per se; it’s about
engineering because engineering is impossible without the
concept of interfaces.
6.3 Summary
▪ Type-level domain models are nice to have, but only if
there are enough justifications for that. Otherwise,
value-level models should be preferred.
▪ Reusing the ADT structure for both type- and value-level
domain models is possible. However, this might require
some type-level magic and may negatively impact the
code's complexity.
▪ The Granular Type Selector design pattern, along with the
HKD design pattern, helps alter the fields of the ADT
structure to support several sets of types unique for each
level.
▪ Type-level and value-level values can also be called static
and dynamic, respectively.
▪ Static Materialization is an approach to making dynamic
value-level models from static type-level ones.
▪ Don’t mix domain models and data-transferring models
(DTOs). Keeping them separate is better because they can
have drastically different shapes.
▪ There are various functional interfaces, but the Free
monad is the most idiomatic interface-like abstraction of
all.
▪ Type class as an interface-like abstraction works poorly
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 190
because it needs proper encapsulation and tends to leak
details into the client code.
▪ Type class best works as a genericity-like abstraction.
▪ If used for interpreting dynamic and static domain models,
type class interface may require additional tricks such as
Dynamic Payload design pattern and some type-level
tricks.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 191
Part III
Advanced type-level design
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 192
Chapter 7
Use case: type-level
object-oriented programming
This chapter covers
▪ How to implement a type-level object-oriented model
▪ How to implement a type-level imperative scripting model
▪ What is the Expression Problem
Appetite comes with eating. When doing domain modeling, we
feel too restricted and limited by our structures that simply
describe things, and we want something more actionable, alive,
and dynamic. Static type-level programming can be enjoyable,
but as the complexity of the domain increases, the associated
type-level domain model can become increasingly convoluted.
In this context, encoding the domain directly—where types are
a copy-paste of the domain—will create numerous specific
type-level mechanisms mimicking the mechanisms from the
domain itself. These mechanisms might involve various
individual tricks and ideas, ultimately deviating from the'Dumb
but Uniform design principle. We’ll do better: invent an
evaluation model that is universal enough to support a
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 193
particularly rich domain, yet it will be small and generic, but not
at the expense of simplicity.
How do we do that? We need to look at the domain from a
different angle. We have to see it as a collection of notions
(nouns) and transformations of these notions (verbs).
The Turing Machine application had states and transitions and
cell reading and writing operations. Still, a more realistic state
machine would have input and output actions to allow solving
tasks beyond pure string rewriting algorithms. We could use
such a universal tool for making interactive fiction games or
word processors. These programs, at least, would need the
input and output possibilities to communicate with the user and
also require external storage. Therefore, some way to script
these input-output scenarios (verbs) should exist.
For the Cellular Automaton application, we can think of extra
mechanics so that the world is now entirely composed of cells
that interact and possibly have internal memory. Imagine cells
that, even being separated by huge spaces, may affect each
other as the entangled particles do in quantum physics. Some
cells may even have free will to become their characters in this
zero-player game. These characters can evolve, use the objects
they meet in the world, and pleasure us with some unexpected
and mind-blowing situations. Therefore, there should be a
rigorous world object model (nouns).
This is how I came to the idea of Zeplrog, which stands for
“zero-player rogue-like game.” I just joined the cell automata,
an ancient rogue-like game, and the idea that it can play itself
with some AI, possibly with GPT integration for the narratives in
the world. I wanted a simulation game that would simulate
some life and allow the user to influence the world so that the
AI-driven protagonist could learn new skills and progress
through the objectives.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 194
LINK The Zeplrog project (notice that it is far from
finished at this book's writing).
https://github.com/graninas/Zeplrog
This chapter will explain how to reinvent your paradigm even if
the language doesn’t support it, using the Zeplrog as an
example.
7.1 Multiparadigm approach
Zeplrog is a Haskell project. It was clear that I needed an
object-oriented model for world objects and an imperative
evaluation model scripting. Haskell is not an object-oriented
language; it’s primarily functional and imperative, but
implementing some OOP is undoubtedly possible. Once I had
already started working on type-level approaches for the book,
I decided to implement this at the type level and gain some
new knowledge about type-level eDSL design.
Would I implement a type-level object-oriented model in
another language? Probably not, because the languages provide
enough built-in features for me not to want my own. But
sometimes, it makes sense to implement functional type-level
models in object-oriented languages. For example, C++ has
templates and constant expressions that are known to be pure
functional sublanguages. So, the approaches presented in this
and the next chapters may still be useful because modern
programming is all multiparadigm.
7.2 Glimpse of typed object-oriented
programming
This section will briefly discuss the circumstances that led me to
invent a type-level object-oriented model. It wasn’t only a need
to test approaches for the book; it was also required by the
domain I chose for experimenting. It turned out that Zeplrog, a
rogue-like game, would be better organized not in a functional,
declarative way but with inheritance and subtyping. Take a look
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 195
at the sample type-level description of the object
SpecificDoor that inherits some properties from
AbstractDoor:
Listing 7.1 Specific door sample
type SpecificDoor = DerivedProp ESpecificDoor AbstractDoor
'[ PropKeyVal EIcon (OwnVal (IconVal "?"))
, PropKeyVal EHP (OwnVal (HPTagVal 50))
, PropKeyVal EPos (OwnVal (PosTagVal 2 3)) -- overridden
]
'[]
Schematically, this can be represented as follows:
Figure 7.1 Example of a property system
This is what we’re going to build.
7.2.1 Zeplrog: the concept
Zeplrog was born from the idea of merging self-playing cellular
automata and rogue-like games. I dreamed about a game in
which the player doesn’t directly control Zeplrog, the
protagonist, but rather constructs the world around them so
that Zeplrog can travel through the obstacles and learn to
reason.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 196
Figure 7.2 Zeplrog, an early edition
The concept is very ambitious. I have identified two main
requirements, each of which is big and complex enough to be
unsatisfied with the existing solutions and require my unique
approach.
First, the world is supposed to be full of standard objects. These
are, for example, weapons, magic tools, various monsters,
some food and healing potions. But I also wanted to have some
interactive objects: traps, levers to pull and engage bridges,
lockable doors, and lifts. While the actual game design is yet in
progress and the exact content is yet to be defined, it was clear
that I needed some entity model and scripting that could allow
me to program such objects.
Second, learning and AI. The protagonist should navigate
across the world, make decisions, learn new stuff, and progress
without direct control from the user. Learning for Zeplrog means
stating goals, choosing tools, observing the events around,
discovering new information from various sources, and
organizing personal knowledge into an ontological knowledge
graph.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 197
Defining the structure of this ontology graph is crucial for the
game's overall functioning. If done incorrectly or understood
poorly, the graph will not work as expected and may cause
severe problems when new requirements are coming, especially
when doing this on the type level.
I found that I needed to maintain two kinds of graphs:
▪ Initial world graph (let’s call it universal). Completely
describes the world: objects, events, effects, interactions,
and cetera.
▪ Personal graph for each actor. This graph holds everything
an actor knows and allows them to pursue their goals. The
knowledge can also be wrongly assumed and partially
learned, so there should be a possibility to correct and
improve the data in the graph.
These two separate ontologies should be interconnected so that
the actor can discover new things and match them to the
general information about the world. The elements in the
personal graphs should somehow reference the nodes in the
general graph to enable good querying possibilities.
After some brainstorming with pencil and paper, I concluded
that both personal and universal graphs should have static and
dynamic forms. A static ontology is predefined from the start
and will not change, contrary to the dynamic ontology, which
will take updates constantly, perhaps concurrently.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 198
Figure 7.3 Property graph
I called every entity “property”. Static and dynamic properties
form a directed graph. No straight cycles are allowed, but there
can be “weak” cycles made with the “secondary-class”
reference edge. The edges can express the relations “is a part
of,” “references to,” “is a subclass of a superclass,” and some
others. For example, in figure 7.3, there is the dynamic
property “guard root prop” that points to the static property of
the same name, and both point to the child properties such as
hp (hit points) or pos (position). The figure doesn’t really
reflect any domain part but illustrates a probable design of the
ontology. I draw many such pictures in order to investigate the
domain. I literally connected the dots in the demo graphs while
connecting the dots in my understanding of the upcoming task.
7.2.2 The Expression Problem
Adding new objects should keep the core logic the same, but
quite often, new and old objects need to interact without
knowing about each other. This is a common requirement we
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 199
can meet in various domains, and it concerns the system's
extensibility. Let’s consider an example.
Imagine the world has regular doors that can be opened and
closed. Next time, we decided to add a secret door. It would
inherit many properties of the regular door (for example,
durability) and bring its own. It closes as usual, but the
character should have a key to open it. Also, this door has a
different icon and sound. We want to add another type and
accompany it with its properties and slightly updated behaviors.
Another example is introducing a new spell, such as freezing. It
should affect the existing properties of the creatures, such as
hit points and movement speed, and dynamically add the
property of preserving food from rotting.
The solution should support “noun” extensibility – when you can
plug user types (new properties in the game’s terminology) into
the system without changing the interfaces. Also, I foresaw the
requirement of “verb” extensibility – when new behaviors can
start working for the existing types and objects without any
unique hacking. This double-faceted problem of “noun” and
“verb” extensibility has a name not widely known outside of
Computer Science circles: The Expression Problem.
● “Noun” extensibility. Allows adding a new notion, a type,
into a well-established set of types. This often requires
subtyping and inheritance or duck typing. (Duck typing is
when a class is considered a subtype of another class
implicitly, without direct inheritance, if both have the
same methods.) Object-oriented languages work better
with “noun” extensibility.
● “Verb” extensibility. Allows adding a new behavior, a
function, an algorithm in addition to the existing set of
behaviors. This can be done with first-class functions, so
functional languages shine in this extensibility kind.
Table 7.1 Paradigms and extensibility
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 200
“noun” “verb” extensibility both
extensibility simultaneously
Object- Easy Possible Very difficult
oriented
Traits, interfaces Extension methods
Subtyping, First-class functions
inheritance
Lambdas
Duck typing
Functional Complicated Easy Very difficult
Generics First-class functions
Extensible records Lambdas
Higher-Kinded
Types
Type families
Type classes
Other advanced
type-level features
As the table shows, it is challenging to support “noun”
extensibility in Haskell, mainly due to the complexity of
type-level tools. We’ve already discussed this when talking
about interface-like abstractions and made sure that even
Haskell needs interface-like abstractions for this specific
extensibility. However, supporting both “noun” and “verb”
extensibility while working together is not only difficult; it’s
often drastically challenging, regardless of the language and
paradigm. Fortunately, it’s almost never true that some domain
requires both types simultaneously; more often, the domain
can be split into chunks where only one of these extensibility
kinds is needed.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 201
LINK Eli Bendersky, The Expression Problem and Its
Solutions
https://eli.thegreenplace.net/2016/the-expression-proble
m-and-its-solutions/
Luckily or unluckily for us, the Zeplrog domain is such. At least,
I wanted to see it like that to make a point for this book, so I
decided to implement an object-oriented model in a functional
language. Its type-levelness will also bring me several
interesting properties to benefit from, and we’re finally going to
examine what has been achieved so far.
7.3 Type-oriented object model
Let’s now briefly discuss the design and implementation
decisions of the type-oriented object model. I’ll only talk about
interesting parts because the solution is too big. I hope you’ll
find several new ideas for eDSL design here and will learn a
couple of new type-level tricks.
7.3.1 Property model
The core concept of this model revolves around the notion of a
'property.' In my terminology, a property is an organizational
unit that, at first glance, might resemble a Java class, but it's
more accurately compared to a JavaScript prototype. A
property can contain fields, and properties can be structured
into a hierarchy, with an inheritance mechanism that includes
both abstract properties to inherit from. Unlike classes,
however, properties do not have methods but instead can
include scripts. These scripts are more powerful than methods,
as they can embed user-defined logic, use property fields, and
access other properties in the graph.
Let me show you a property SimpleLamp with an on/off status.
It has to be derived from another property that we’ll keep
behind the curtains for a while:
type ESimpleLamp = Ess @TypeLevel "lamp:simple" #A
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 202
type EIsOn = Ess @TypeLevel "is on" #B
type SimpleLamp = DerivedProp ESimpleLamp AbstractLamp #C
'[ PropKeyVal EIsOn (OwnVal (BoolValue False)) #D
]
'[] #E
#A Identifier for this property
#B Identifier for the on/off field
#C SimpleLamp is a derived property
#D Boolean field with own property’s value
#E Empty list of scripts
The first thing to notice is that every property should have an
ID, a unique type-level string with possibly a mnemonic
description of the property. (Uniqueness is not checked at the
moment.) I called these IDs “essences”; it’s a fun term that
suits the game domain well. Ess is a short form of it, and the
type (as well as the whole property language) uses the
Granular Type Selector pattern:
data Essence (lvl :: Level) where
Ess :: StringType lvl -> Essence lvl
Essences allow me to name objects: rat, guard, wand. Every
class of game objects should have a property representation,
and it’s pretty natural to draw such graphs with the new
notation I developed for it:
Figure 7.4 Simple static properties graph
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 203
What is AbstractLamp? It’s certainly a property that provides
its followers with some fields and scripts. It happened so that
the is-on field was redefined in SimpleLamp, but it could have
been taken from AbstractLamp:
Listing 7.2 Abstract property example
type EAbstractLamp = Ess @TypeLevel "lamp:abstract"
type ESwitchScript = Ess @TypeLevel "script:switch"
type SwitchScript = ... -- omitted for now
type AbstractLamp = AbstractProp (Group EAbstractLamp) #A
'[ PropKeyVal EIsOn (OwnVal (BoolValue False)) #B
]
'[ PropScript ESwitchScript SwitchScript #C
]
#A Abstract property
#B Predefined field
#C Predefined script
In this
snippet, we see two new notions: Group and
PropScript. The latter is clear, but what is a Group? The
model has several ways to group similar notions. These are:
▪ Inheritance chains. Inheritance unifies all subproperties
into a logical group. Inherited properties know their
parent, and it’s possible to query the parent property and
then the subproperties. There can be several abstract
properties in the inheritance chain. For example, concrete
prop derives abstract prop 1 that derives abstract prop 2.
(See figure 7.4. and listing 7.3)
▪ Property groups. Every property will belong to a group that
comes with the parent abstract property. In listing 7.2, the
abstract property belongs to the EAbstractLamp group,
as do all derived properties. There is no specific querying
mechanism for this groping, but I believe it can be added.
(See figure 7.5.)
▪ Property chains. Organizing properties into a one-directed
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 204
chain can also be done via the Group mechanism. These
chains differ from inheritance because the properties are
not mandated to have something in common (although
this will be the most usable case). (See figure 7.5.)
▪ Tag properties and tag property chains. It’s often needed
to have static properties with no fields and even with no
possibility to instantiate them as dynamic ones. Such
static-only properties would top a property chain, and the
very fact that the chain has a tag property can
communicate something in the ontology graph. Tag
properties can be organized into chains. (See figure 7.6.
and listing 7.4).
Figure 7.5 Inheritance chain and grouping
Listing 7.3 Abstract properties chain example
type AnyProp = AbstractProp (Group EAnyProp) '[] '[] #A
type AbstractDerived1 = AbstractDerivedProp #B
EAbstractDerived1 AnyProp '[] '[]
type AbstractDerived2 = AbstractDerivedProp
EAbstractDerived2 AbstractDerived1 '[] '[]
type ConcreteProp = DerivedProp #C
EConcrete AbstractDerived2 '[] '[]
#A Abstract property
#B Intermediate abstract property
#C Concrete property
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 205
Figure 7.6 Properties chain
Figure 7.7 Tag properties chain
Listing 7.4 Tag properties chain example
type EQuantuumState = Ess @TypeLevel "quantuum state"
type EAlive = Ess @TypeLevel "alive"
type ESchrodingerCat = Ess @TypeLevel "Schrodinger cat"
type QuantuumState = TagProp (TagGroup EQuantuumState)
type Alive = TagProp (TagGroupRoot EAlive QuantuumState)
type SchrodingerCat
= AbstractProp (GroupRoot ESchrodingerCat (TagPropRef Alive))
And look, the sample references the famous quantum thought
experiment with a cat. It’s pure coincidence that the tag
property and tag group icons slightly remind the quantum state
symbol “ket”: |ψ⟩!
The last feature of this model is child properties and owning
hierarchies (“has-a” relation). Properties can have an arbitrary
number of other properties as children, resembling the concept
of composition from object-oriented programming. Also,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 206
properties can refer to other properties without owning them.
Figure 7.8 demonstrates this schematically, although neither
property can live without its abstract template not shown here.
Figure 7.8 Child properties
Another look at the property is that it’s a key-value dictionary of
fields. This makes it a sibling of Python’s classes, which is
actually good because adding and removing fields on the fly
opens powerful possibilities when programming dynamic
ontologies. This is where we should discuss static type-level,
static value-level, and dynamic properties and describe the
“two-phase compilation” approach.
7.3.2 Static materialization and dynamic instantiation
In the following example, SimpleLamp is a type for a specific
property derived from another property (AbstractLamp):
type SimpleLamp = DerivedProp ESimpleLamp AbstractLamp
'[ PropKeyVal EIsOn (OwnVal (BoolValue False))
]
'[]
The core types of the property model, such as DerivedProp,
PropKeyVal and OwnVal, are implemented as previously. They
are multi-level GADTs similar to those we developed previously.
Take a look at listing 7.5. DerivedProp can only exist on the
type level, and PropDict is solely purposed for the value level.
I make other usages impossible by specifying the returning type
of each constructor. PropertyTL, which shortens Property
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 207
'TypeLevel, marks DerivedProp for being type-level only,
and PropertyVL does a similar thing to PropDict:
Listing 7.5 Static property made with GADTs
type PropertyTL = Property 'TypeLevel #A
type PropertyVL = Property 'ValueLevel
data Property (lvl :: Level) where
TagPropRef :: TagProperty lvl -> Property lvl #B
DerivedProp :: EssenceTL
-> AbstractProperty]
-> [PropertyKeyValueTL]
-> [PropertyScriptTL]
-> PropertyTL #C
PropDict
:: PropertyGroupVL -> [PropertyKeyValueVL]
-> [PropertyScriptVL]
-> PropertyVL #D
#A Short names for value-level and type-level models
#B Generic: works on both levels
#C Works on the type level
#D Works on the value level
Nicely for us, type-level DerivedProp can’t have runtime
values because the essence type is also type-level (hence, it
has TL in its name) and internally uses Symbol. This also
means we can’t pattern-match this GADT constructor on the
value level, and the compiler will understand this. PropDict
and TagPropRef will be the only patterns to provide:
instantiate :: Static.PropertyVL -> Dynamic.Property
instantiate (TagPropRef tagProp) = ...
instantiate (PropDict group fields scripts) = ...
instantiate (DerivedProp ? ? ? ?) = ... #A
#A Impossible to provide a value; the pattern is not needed
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 208
I don’t even need to share value constructors between levels. I
can specify separate ones for each level, as I did this for the
grouping type:
data PropertyGroup (lvl :: Level) where
Group :: EssenceTL -> PropertyGroupTL #A
GroupId :: EssenceVL #B
-> StaticPropertyId
-> PropertyGroupVL
#A Type-level only
#B Value-level only
Constructors differ much because GroupId contains an
identifier for each static property that is absent on the level of
types. It’s certainly possible to keep only one value constructor
with a field of an optional type that will either hold or not the
static ID, but I decided that this is a simpler approach, all with
the help of GADTs.
NOTE Various languages support GADTs. See Rosetta
Stone for more info.
The static property identifier will be assigned to a property
during static materialization. This process transforms the static
type-level model into a static value-level model, as we did in
previous chapters. This process resolves the derivings and
forms static property prototypes ready to be instantiated at
runtime.
Dynamic instantiation, in turn, transforms static value-level
properties into dynamic value-level properties (produces
concrete “objects” of “classes”). Every dynamic property
references its source static property for grouping and querying
data. The graph, therefore, will contain both static and dynamic
value-level properties, as previously shown in figure 7.3. New
properties can be introduced by additional instantiations, and
it’s possible (although not fully implemented) to reconstruct
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 209
properties at runtime, including their fields, values, and the
whole scripts.
Figure 7.9 Two-phase compilation process
I don’t think I need to reveal the materialization and
instantiation machinery; you can always examine the code for
this chapter and the code of Zeplrog. You’ll see that the
materialization code is typical and boilerplaty.
class SMat payload a b #A
| payload a -> b where #B
sMat :: payload -> Proxy a -> SMaterializer b
instance ( KnownSymbol symb ) =>
SMat () ('Ess @'TypeLevel symb) EssenceVL where #C
sMat () _ = pure (Ess (symbolVal (Proxy @symb)))
#A Materializer
#B The b type is defined by payload and a
#C Converting type-level Essence to value-level one
In my defense, I’ll say that it’s possible to make the compiler
derive the instances of SMat with the help of metaprogramming
or generic programming. In Haskell, it’s TemplateHaskell and
GHC.Generics, but how to do this is a story for another day.
What’s more interesting is that in Zeplrog, extra mechanisms
help to join world data and the knowledge graph, a
two-dimensional array of cells, like in the cellular automata.
type World = WorldData @TypeLevel
'[ "#############"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 210
, "#...........#"
, "###?####.####"
, "#...........#"
, "#############"
]
The world provides surroundings and objects, and the
instantiator associates them with static properties using the
icon value. The instantiator then spawns dynamic properties in
the needed location, and these new properties join those
produced from the graph. Yes, this domain is not trivial, and
I’m glad I managed to implement everything with type-level
programming, although I would not recommend doing it for
production unless there is a real need.
7.3.3 Scripting
It’s possible to attach an arbitrary number of “imperative”
scripts to each property on the type level. An example of a
script that switches a lamp on or off is presented below.
Listing 7.6 Script example
type SwitchVar = BoolVar "switch" 'False #A
type SwitchScript = 'Script @'TypeLevel "Inverts EIsOn"
'[ DeclareVar SwitchVar #B
, ReadData (FromField 'Proxy '[EIsOn]) (ToVar SwitchVar) #C
, Invoke NegateF (FromVar SwitchVar) (ToVar SwitchVar) #D
, WriteData (ToField 'Proxy '[EIsOn]) (FromVar SwitchVar) #E
]
#A Variable definition with a default value and name
#B Declares a variable
#C Reads data from the field into the variable
#D Negates the boolean variable
#E Writes data back from the variable to the field
The script declares a boolean variable, reads a field into it,
negates the variable, and writes the result back into the field.
The script was intentionally made long, but it could consist of
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 211
only one Invoke instruction that would negate the target field
directly.
Notice that the list of essences ('[EIsOn]) is a reference to
some object relative to the parent property’s root. So when the
path is '[EEss1, EEss2, EEss3], the runtime will traverse
the children of the property to find EEss1, then descend to the
next property EEss2, and finally will look up for EEss3. This
way of referencing allows the scripting subsystem to access the
graph and arbitrary parts of it. Attentive readers may
remember XPath, – an XML technology with a similar purpose
of addressing arbitrary parts of XML with a path-like
domain-specific language.
The script feels quite imperative, so we merged impartiality and
object orientation with pure functional programming. We also
benefited from type levelness here because the language's
design preserves type safety when possible, for example, when
writing from and to variables and fields. Let me briefly describe
how this is achieved.
Fields can contain values of a limited number of types. All the
available types are enumerated with ValDef. This is an
excerpt:
data ValDef (lvl :: Level) where
IntValue :: IntegerType lvl -> ValDef lvl
BoolValue :: Bool -> ValDef lvl
...
NOTE: You will find this solution in the book's repository. It
can’t be extended in any way other than updating ValDef.
But even so, some types can’t be lifted to the type level,
for example, function types. Besides, ValDef doesn’t
support user-defined types such as User or Person. The
solution is closed for extension, so it doesn’t respect the
Open-Close design principle (OCP). However, I
implemented an improved, fully extensible version in the
Zeplrog project. You’ll find its description in Appendix D.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 212
And remember: With great power comes great
complexity.
When promoted, each value constructor will become a type that
“subtypes” the ValDef kind (pseudocode):
kind ValDef 'TypeLevel
type IntValue (i :: Nat)
type BoolValue (b :: Bool)
...
To declare a variable for a script, we use VarDef, which
references ValDef to store default values. VarDef strengthens
the type safety with a specific type tag as an additional type
parameter:
data VarDef (lvl :: Level) typeTag where
GenericVar
:: StringType lvl -- ^ Var name
-> ValDef lvl -- ^ Default value
-> StringType lvl -- ^ Stringified type name
-> VarDef lvl typeTag
This is a generic definition, and it should be accompanied by
several helpers for each contained type:
type IntTag = "tag:int"
type BoolTag = "tag:bool"
type IntVar (name :: Symbol) (i :: Nat)
= GenericVar @'TypeLevel @IntTag name (IntValue i) IntTag
type BoolVar (name :: Symbol) (b :: Bool)
= GenericVar @'TypeLevel @BoolTag name (BoolValue b) BoolTag
In particular, SwitchVar is BoolVar from listing 7.6. It carries
BoolTag behind the scenes.
Suppose now that we’re trying to read and write variables of
mismatch types:
type MyBoolVar = BoolVar "test1" 'False
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 213
type MismatchVar = StringVar "test2" "abc"
type TestScript = 'Script @'TypeLevel "Test"
'[ DeclareVar MyBoolVar
, DeclareVar MismatchVar
, WriteData (ToVar MismatchVar) (FromVar MyBoolVar)
]
This won’t compile with a sane compilation error:
Couldn't match kind ‘"tag:bool"’ with ‘"tag:string"’
Expected kind ‘Source 'TypeLevel StringTag’,
but ‘FromVar MyBoolVar’ has kind ‘Source 'TypeLevel BoolTag’
This is achieved by mandating the same type tag for the
WriteData instruction, which is a part of the ScriptOp type:
data ScriptOp (lvl :: Level) where
WriteData
:: Target lvl typeTag #A
-> Source lvl typeTag
-> ScriptOp lvl
DeclareVar :: ...
Invoke :: ...
#A Type tags must be identical
Writing to and from variables and reading from constants is
type-safe and resolved at compile time. However, an interesting
problem occurs when instantiating a script. Variables all have
their types, and potentially, they can keep an arbitrary
user-defined type. When a script starts, we need to store its
variables in runtime and access them during the evaluation.
How would we do that uniformly if the types of variables are
unknown?
Homogenous collections are again our obstacle. The
existentification and valuefication approaches may work, but I
foresee many complications. Heterogenous collections don’t
solve the problem either, as they can’t operate on the value
level only. Essentially, the whole property and variable model is
a huge heterogeneous collection, so adding more type-levelness
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 214
won’t solve our runtime task. This is where I use the
Typed-Untyped design pattern.
7.3.4 Typed-Untyped design pattern
I first presented the Typed-Untyped design pattern in my
Functional Design and Architecture book. I was solving a similar
problem: there were variables (StateVar a) of arbitrary
user-defined types that my framework, Hydra, should have
stored internally without losing type safety. I couldn’t just put
original variables into a collection, so I made them untyped
(GHC.Any), and in this form, I could define internal dictionaries
of variables. The outside world, business logic, was still
operating with StateVar a, a fully typed representation of the
user’s data. Also, this entity was a hint for the framework of
what type to restore from the untyped form.
Figure 7.10 describes the design pattern for the scripting
model:
Figure 7.10 Typed-Untyped design pattern
In my scripting subsystem, VarDef (lvl :: Level)
typeTag entities don’t contain the actual mutable value. These
entities are responsible for remembering the user-defined type
via typeTag, and providing it by its simple presence in type
signatures. They also serve as a key to access the actual
mutable variables stored in runtime in an untyped form:
import GHC.Any (Any)
data IScrRuntime = IScrRuntime
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 215
{ iScrVars :: IORef (Map String (IORef Any)) #A
}
#A Dictionary with untyped variables
Therefore, the VarDef entities become avatars of these data,
so I called this approach the Typed Avatar design pattern, which
complements the Typed-Untyped design pattern.
Scripts don’t have a dynamic value-level form because they
don’t change. The scripts are either static type-level or static
value-level after materialization. A script is attached to some
dynamic property and can be evaluated as a reaction to some
external event. The evaluation mechanism requests the script
from the static model of the dynamic property. It starts
interpreting the list of instructions as we would typically do with
other interpretable languages.
type ScriptInterpreter a = ReaderT IScrRuntime IO a
class IScr it a #A
| it -> a where #B
iScr :: Property -> it -> ScriptInterpreter a
#A Script interpreter type class
#B A thing to interpret defines the output type
Every reading and writing instruction is fully typed because it’s
from the static value-level model, so the interpreter is always
aware of the types of variables.
instance IScr ScriptOpVL () where
iScr prop (DeclareVar varDef) = ... #A
iScr prop (WriteData target source) = #B
readWrite prop Nothing source target
iScr prop (Invoke func source target) =
readWrite prop (Just func) source target
#A A variable will be created and stored untyped
#B The target and the source are fully typed
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 216
Using this type info, the readWrite function knows when to
convert to and from GHC.Any with unsafeCoerce and casting
to an invalid type for variables is not possible. However, there
are two limitations.
First, ValDef allows for only a given set of stored data: strings,
integers, booleans, a list of essences, and a couple of service
types. ValDef is not extensible by any means:
data ValDef (lvl :: Level) where
IntValue :: IntegerType lvl -> ValDef lvl
BoolValue :: Bool -> ValDef lvl
StringValue :: StringType lvl-> ValDef lvl
PairValue :: ValDef lvl -> ValDef lvl-> ValDef lvl
PathValue :: [Essence lvl] -> ValDef lvl
TagValue :: TagProperty lvl -> ValDef lvl -> ValDef lvl
Second, the type checking for fields with this approach is
challenging. It can’t be fully implemented on the level of types
without redesigning ValDef. The compiler won’t help in some
cases, but there will be a runtime error for mismatched types.
For example, writing a boolean field from a string variable will
raise an exception. There are several ways to improve the
model, but all have a considerably higher accidental complexity.
As a matter of fact, I only kept inextensible poorly
type-checkable ValDef for the book’s repository. The Zeplrog
project itself has an evolved version with both issues fixed. See
Appendix D Extensible value definition model for more info.
I love the Zeplrog concept and liked implementing its model for
the book. In Zeplrog, the dynamic properties are even more
“alive”; they are actors with, possibly, special world effects that
trigger scripts and with scripts that affect the world and other
properties. Being fully implemented, scripting somewhat solves
the Expression Problem because it’s now easy to construct new
“nouns” (properties) and assign them with “verbs” (scripts) on
the fly. The next chapter will discuss a more rigorous method of
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 217
addressing this problem and provide another interesting
evaluation model.
7.4 Summary
▪ Static type-level programming can be complex and
restrictive, but a universal evaluation model can support
rich domains without sacrificing simplicity.
▪ Utilizing multiple paradigms, such as functional and
object-oriented programming, can enhance a project's
flexibility and power.
▪ The so-called Expression Problem describes a difficulty of
simultaneous extensibility in two directions: adding new
types (nouns) and behaviors (verbs) to a system without
modifying existing code.
▪ Implementing a type-level object-oriented model and
imperative scripting model is possible, even in non-OOP
languages like Haskell, and can help solve the Expression
Problem.
▪ Type-level object-oriented models can be particularly
beneficial for organizing complex domains with many
distinct notions and behaviors.
▪ Generalized Algebraic Data Types (GADTs) can empower
type-level domain modeling and enable useful type safety
tricks.
▪ The Typed-Untyped design pattern helps store variables of
arbitrary types while preserving type safety. This is
achieved by clearly separating the fully typed business
logic from the untyped runtime.
▪ The Typed Avatar design pattern is when some value
doesn’t hold the actual data but represents evidence of a
specific type to request the data from the untyped source
and to not violate type safety.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 218
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 219
Chapter 8
Use case: advanced
extensibility
This chapter covers
▪ What can be used as a type-level interface-like abstraction
▪ How to make well-organized and extensible systems
▪ How to model with empty parametrized ADTs
Extensibility revolves around two notions: interfaces and
extension points. The two may seem causally related at first,
but they are independent. Miss one of them, and it will be
something else, not extensibility.
An interface with a single implementation may work as a
decoupling and simplification mechanism. It’s not an extension
point, and there are no plans to add more implementations.
Such interfaces divide responsibilities, weaken the relations
between subsystems, and make programming easier. After all,
abstraction and simplification, not extensibility, are the primary
goals of interfaces.
In turn, there can be an extension point without an interface. It
often looks like a bunch of hardcoded options supplemented by
a verbal agreement. Once a new implementation comes, this
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 220
code should be updated. You can find it by looking for
switch-case constructions:
-- Add payment gateways here
case paymentGateway of
"Juspay" -> processJuspay
"PayU" -> processPayU
"Razorpay" -> processRazorpay
_ -> error "Gateway not supported"
Or it can be a list with predefined values:
-- Add payment gateways here
paymentGateways =
[ ("Juspay", processJuspay)
, ("PayU", processPayU)
, ("Razorpay", processRazorpay)
]
The example has a conventional extension point but no
interface that would abstract the subsystem. This situation
worsens when the project has several such places, all of which
should be updated simultaneously once a new subsystem
arises. It’s easy to forget something; the code is highly coupled,
implementation details leak, testability is awful, and the project
structure is confusing.
In this chapter, we’ll fill the gap in the table of extensibility
mechanisms. We’re going to learn the type-level column:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 221
Figure 8.1 Extensibility approaches
This chapter contains the finishing touch on the canvas of the
Pragmatic Type-Level Desing methodology. The rest of the book
is dedicated to the Rosetta Stone part and the appendices. I’m
glad we went so far and now can construct our high-quality
applications consciously.
8.1 Advanced type-level design and extensibility
The new approach to extensibility is very Haskell-related. It
may not be directly applied in other languages, but it can at
least be a prior art for other languages to follow. It requires
GADTs, type families, multiparam type classes with flexible
instances, and type promotion to the level of kinds, or a similar
set of features. The compiler should also have reasonable type
inference to avoid as many type specifications as possible.
Nevertheless, we can mimic the approach more or less
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 222
accurately in C++, OCaml, F#, Rust, and Scala. See Rosetta
Stone for comparison.
8.1.1 Domain modeling with empty parametrized ADTs
In some sense, empty parametrized ADTs (let’s abbreviate
them as eADTs) will be simpler for domain modeling than the
promoted ADTs we used previously. At least, eADTs are explicit,
and you don’t need to keep in mind that there is something the
compiler generates for you when the DataKinds extension is
enabled. Take a look at the types we crafted in Chapter 3:
data Person (firstName :: Symbol) (lastName :: Symbol)
data User (login :: Symbol) person
type MandelbrotPerson = Person "Benoit" "Mandelbrot"
type MandelbrotUser = User "mandel" MandelbrotPerson
It was domain modeling already! Yes, the model is tiny, but why
belittle our early achievements? The model wasn’t perfect,
though. Let’s fully specify it by putting the optional star kind to
the person field where it belonged implicitly:
data User (login :: Symbol) (person :: *)
This means that we can pass any regular type, for example,
Bool:
type WhatAmI = User "invalid user" Bool
This “star freedom” is not quite welcomed in domain models
because it makes meaningless types possible. To fix this, we
can, in principle, set person to be of Person kind, which we
have implicitly generated. Notice that the kind itself is
parametrized twice according to the previous definition:
data User (login :: Symbol) (person :: Person fn ln)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 223
Figure 8.2 User and Person relationship
This produces another problem: we can’t pass the regular type
MandelbrotPerson into this field because
MandelbrotPerson has kind *, not Person. The model is now
inconsistent.
We can partially fall back to usual type-level ADTs to solve this
problem. Mixing the two approaches to type-level domain
modeling works and is sometimes convenient. See listing 8.1:
Listing 8.1 Mixing empty ADTs and regular ADTs
data PersonType = Person #A
{ firstName :: Symbol
, lastName :: Symbol
}
data User #B
(login :: Symbol)
(person :: PersonType) #C
type MandelbrotPerson = Person "Benoit" "Mandelbrot"
type MandelbrotUser = User "mandel" MandelbrotPerson #D
-- type WhatAmI = User "invalid user" Bool #E
#A Regular ADT (generates a promoted one, also)
#B Empty parametrized ADT
#C Using the PersonType kind for type safety
#D Compiles
#E Doesn’t compile
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 224
Domain modeling with only eADTs is also possible, which is
valuable because it is less wordy and enables a lovely type-level
extensibility approach. Let’s sort it out.
8.1.2 Type-level interfaces
Type-level interfaces have most of the properties of a true
interfacing mechanism. From chapter 6, we know such
properties: abstraction, interface segregation, multiple
implementations, and encapsulation. Type-level interfaces are
capable of hiding information and doing all of that. The only
distinction between these interfaces and the usual ones is that
there is no runtime or lifetime of implementation because both
are just types. Type implementations can have some fields
encapsulated and unseen to the outside world, and the client
code, which is our actual domain-related definitions, will be
unaware of those implementation details due to the
encapsulation. Other than that, this technique will also increase
type safety and type-level eDSLs expressivity. It’s so powerful
that it would be easy to recreate and even make a better
version of an interesting library like Servant – Haskell’s
type-level eDSL for HTTP APIs. Using this technique, you’ll be
able to craft nice type-level UI libraries, parsing libraries, and
better SQL connectors with much pleasant UX than we have
today with Haskell’s Esqueleto, Beam, Opalyeye, and Squeal.
As an example, we’ll revisit the cellular automata rule model.
Let me remind you how we used to write the rules:
Listing 8.2 Cellular automaton rule
type A = State "Alive" 1 #A
type D = State "Dead" 0
type Neighbors3 = NeighborsCount A '[3 ] #B
type Neighbors23 = NeighborsCount A '[2,3]
type GoLStep = 'Step ('DefState D) #C
'[ 'StateTransition D A Neighbors3 #D
, 'StateTransition A A Neighbors23
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 225
type GoLRule = Rule #E
"Game of Life"
"gol"
(AdjacentsLvl 1) #F
GoLStep
#A Possible states
#B Neighborhood rules for Game of Life
#C A single step of the automaton with a default state
#D State transitions in the born/alive notation
#E Game of Life rule definition
#F Neighborhood area definition
A plot twist is that it’s not the old way, although it looks similar.
I constructed this rule from the new model. The definitions
didn’t change much except for some types saved their ticks and
some lost. The new model is partially extensible with type-level
interfaces and partially customizable. Those notions that saved
their ticks like 'Step, 'StateTransition, and 'DefState
are customizable data types promoted with DataKinds as usual:
Listing 8.3 Customizable cellular domain types
data CustomStateTransition = StateTransition #A
{ cstFromState :: IState #B
, cstToState :: IState
, cstCondition :: ICellCondition #C
}
newtype DefaultState = DefState IState
data CustomStep = Step
{ defState :: DefaultState
, transitions :: [CustomStateTransition]
}
#A Regular ADT
#B Type-level interface
#C Another type-level interface
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 226
Some of the fields carry the interfaces, namely IState and
ICellCondition. I follow the Java and C# convention of
naming to distinguish them from usual and customizable types,
and maybe there is some distant relation to OOP here. It is too
distant to make Haskell Java but quite close to applying the
design reasoning for our benefit.
IState is a type-level interface and extensibility point for
states that replaced its predecessor, CustomState. See the
following listing:
Listing 8.4 Type-level interface for states
data IState where #A
StateWrapper :: a -> IState #B
type family MkState a :: IState where #C
MkState a = 'StateWrapper a #D
#A Type-level interface (a GADT)
#B Existential field for implementations
#C Wrapping helper
#D Wrapping procedure (uses the promoted type)
From the first look, IState seems to be a regular ADT with an
existential field of type a. That’s right, this is correct. We use its
promoted version as a type-level interface where IState is a
kind and StateWrapper is a type. See this picture:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 227
Figure 8.3 Type-level interface scheme
You also see a type family MkState. It wraps an arbitrary type
a into the existential wrapper StateWrapper on the level of
types. The result is seen as an interface IState, and you can’t
know what’s hidden behind it.
Using this machinery, we can provide independent, isolated
implementations for states, and all of them will be eligible for
IState fields. Implementation is an empty parametrized ADT
that we put behind the interface using the helper type family:
data StateImpl #A
(name :: Symbol)
(idx :: Nat)
type State n i = MkState (StateImpl n i) #B
#A Implementation
#B Put the implementation behind the interface
The State type plays the same role as constructors in OOP; it
is a smart constructor, just on the level of types. We can
statically instantiate the actual types with it:
type A = State "Alive" 1
type D = State "Dead" 0
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 228
Therefore, we have two customized types based on a single
implementation. Adding one more implementation will
emphasize the difference between extensibility and
customization.
Listing 8.5 Second implementation of IState
data ColorType = Red | Green | Blue
data ColoredStateImpl #A
(color :: ColorType)
type ColoredState c = MkState (ColoredStateImpl c)
type R = ColoredState 'Red
type G = ColoredState 'Blue
type B = ColoredState 'Green
#A New implementation
This new implementation is completely independent of the first
one. It may come from another library or project, yet both
State and ColoredState have the same interface to be used
together. For example, you can put them into a type-level list if
you need it:
data States (sts :: [IState])
type MyStates = '[A, D, R, G, B]
The system is now genuinely extensible in this dimension. The
model also has other extension points: ICellCondition,
INeighborhood, and IRule. They all follow the same pattern,
and the code is very uniform.
TIP As an exercise, I partially ported Zeplrog’s
object-oriented property model to this engine, which you
can get familiar with in the book’s repository. You’ll find it
here:
CH08/Section1p3/src/ZeplrogOOP
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 229
TIP You’ll also find another small game called Minefield.
This one is reminiscent of Minesweeper, with some
rogue-like fleur in it. Path in the repository:
demo-apps/minefield.
The last bit of this approach is how we run the interfaces and
the implementations. We use static materialization for this, with
slight changes in the instances. The lists and fields of interfaces
can be interpreted as usual, but there should be an extra step
of unrolling the existential wrapper. As an example, I’ll show
you the Introspection type class. It aims to traverse a rule
and compose a string description of each type-level bit.
class Introspect it where
introspect :: Proxy it -> [String]
Unwrapping StateWrapper is simple (notice we use it
promoted, so we need the tick):
instance
( Introspect st ) => #A
Introspect ('StateWrapper st) where #B
introspect _ = introspect $ Proxy @st
#A Introspecting the contained type
#B Unwrapping the existential type-level value
This instance will proceed with the introspection of what is
hidden behind the interface and will automatically rail to the
corresponding type class instances. For our two
implementations, we can have these introspection instances:
Listing 8.6 Introspection of the two instances
instance
( KnownSymbol name, KnownNat id ) =>
Introspect (StateImpl name idx) where
introspect _ = [ "StateImpl"
, symbolVal $ Proxy @name
, show $ natVal $ Proxy @idx ]
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 230
instance ( Introspect color ) =>
Introspect (ColoredStateImpl color) where
introspect _ = "ColoredStateImpl"
: introspect (Proxy @color)
NOTE The alternative syntax with type equality and type
variables may not work properly here:
instance
(i ~ ColoredStateImpl color, Introspect color ) =>
Introspect i where ...
This will only work for a single implementation but may
fail to compile for multiple implementation instances, and
the GHC error messages will be weird.
The two introspection instances are independent, and they
should not necessarily be provided together. Moreover, if some
side project defines ColoredStateImpl, it can only refer to the
core code with IState, and the core project may not even have
a single implementation or instance. This is how we achieve low
coupling between different parts of the big subsystem, and this
is how we did it with OOP and imperative programming.
The presented extensibility approach produces less boilerplate
in the model customization approach. Mixing extensibility and
customization mechanisms is also good, making our design
more explicit and organized. The proposed naming scheme also
helps with this: ticks and the “Custom” prefix address a
potential domain that can be configured (customized); the “I”
refers to interfaces so that this part of the domain model can be
extended.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 231
Figure 8.4 Orthogonality of extensibility and customization
Dumb but uniform, right? But maybe it’s not even dumb but
rather clever enough to keep the code simple. Doing things
simple is very difficult, and proper abstraction usage is a
precarious balance game in Software Engineering.
8.1.3 Universal evaluation mechanism
Since the beginning of the book, I’ve demonstrated various type
classes for interpreting type-level models. These are
Description, Introspect, SMat, Materialize,
MaterializeList and others. Don’t you feel uncomfortable
having so many incarnations of, in general, one idea? We can
do better. We can craft a universal evaluation mechanism
suitable for all the usage scenarios. We will then be configuring
it with requests on how it should process the types. The code
will be extremely dumb and boilerplate but uniform and
approachable.
Three type classes for type literals immutable as stone,
Seven for domain models, each treated as it’s alone,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 232
Nine for types-interfaces, extensible on their own,
One for Uniformity, not colored like a clown,
In the Land of Types where complexity's overthrown.
We only need the following Eval type class for almost
everything:
class Eval payload verb noun ret
| verb noun -> ret where #A
eval :: payload -> verb -> Proxy noun -> ret
#A Functional Dependency from verb and noun to ret
Some of the bits are easily recognizable because we have used
them already: payload is the Dynamic Payload pattern, noun is
the target type we want to deconstruct (this is why we pass it
as a Proxy noun), verb is what we want to do with the target,
and ret defines a return type.
Introspecting in these coordinates becomes evaluation with the
corresponding verb type:
data Introspect = Introspect
instance
( Eval () Introspect st [String]
) =>
Eval () Introspect ('StateWrapper st) [String] where
eval _ _ _ = eval () Introspect $ Proxy @st
The functional dependency verb noun -> ret says that when
concrete verb and noun types are used together, the ret type
will also be known and unique, and it’s the only ret type
allowed for that pair. You can’t have the following instances that
return different types:
instance Eval () Introspect (StateImpl name idx) [String]
instance Eval () Introspect (StateImpl name idx) [Text]
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 233
This would be somewhat possible without the functional
dependency, but type inference would work much worse. If you
really need something like this, you introduce separate verbs:
data IntrospectToString = IntrospectToString
data IntrospectToText = IntrospectToText
The functional dependency will be happy now (pseudocode):
IntrospectToString + (StateImpl name idx) = [String]
IntrospectToText + (StateImpl name idx) = [Text]
This approach forces you to write more but to remember less.
Just look at the verbs, and you’ll know what you can do with
the domain model. You don’t have to look for strangely named
type classes and think through the intentions behind them. It
also frees you from wandering across the project; put the verbs
into a single place, group them, describe them, and enjoy
clarity.
-- | Returns the icon of an object
data GetIcon = GetIcon
-- | Creates a game actor from the object description
data MakeActor = MakeActor
-- | Creates an action for the player
data MakeGameAction = MakeGameAction
I took these from another showcase project, the Minefield
game. The code demonstrates many interesting concepts, but
maybe the most interesting to us is its advanced extensible
architecture. Let’s talk about that.
8.1.4 Advanced extensibility and the Expression Problem
Let me introduce you to the concept of the game before talking
about advanced extensibility. You’ll find the game in the book’s
repository:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 234
../First-Edition/BookSamples/demo-apps/minefield
Minefield is a Minesweeper younger sister. You are offered a
field full of mines, and you can move your character around as
if it were a rogue-like game. You should detect the mines to put
yourself in safety, and don’t forget that there are not only
landmines but also timer bombs that will eventually explode
when you least expect this.
Figure 8.5 Minefield game with all mines revealed for debugging
You can do various things to the mines: disarm, trigger, and
jump over them to avoid the explosion. The game is in early
development; not all features are implemented yet, and the
concept may change, but it’s an interesting engineering
showcase already because I joined several ideas together:
▪ advanced type-level extensibility for both verbs and nouns
(solving the Expression Problem);
▪ event-driven architecture;
▪ actor model;
▪ type-level decoupling with type-level interfaces;
▪ universal evaluation mechanism;
▪ MVar request-response design pattern.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 235
Learning from this project may give you many practical insights
about the architecture, event-based model, and actors, so I
wrote Appendix E: Event-based architecture of the Minefield
game. You might want to read this appendix to understand the
design choices better. This section will only tell you about the
extensible domain model and how the Expression Problem is
solved here.
Setting up a game starts with defining its parameters: field,
bombs of various kinds, and available player’s actions with
those bombs.
Listing 8.7 Minefield game definition
type Minefield3By5 = #A
'[ "B @ B"
, "8 B"
, " 8 "
]
type MyGame = GameDef
Minefield3By5 #B
(Player "@") #C
(EmptyCell " ") #D
'[ Landmine "B" 2 #E
, TimerBomb "8" 8 #F
]
'[ PutFlag #G
]
#A 3x5 field definition
#B Predefined bomb field
#C Icon of the player
#D Icon of the empty cell
#E Landmine type and icon
#F TimerBomb type and icon
#G Available action: putting a flat
The domain model is extensible. More bomb types (nouns) and
more actions (verbs) can be added independently from the
existing code. Actions like PutFlag affect the field and existing
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 236
field objects. Every introduced bomb type has to react to a new
action, let’s say, Walk. For this to happen, a couple of new type
class instances should be provided anywhere in the code, not
necessarily where the action and the bomb types are defined.
Field objects and actions should implement these interfaces:
data IObjectTemplate where #A
ObjectTemplateWrapper :: a -> IObjectTemplate
data IAction where #B
ActionWrapper :: a -> Command -> IsDirected -> IAction
#A Field objects that will become field actors
#B Anything the player can do
Here is how this looks for PutFlag, the action that disables a
mine:
data PutFlagDef
type PutFlag = MkAction PutFlagDef "flag" 'True
The implementation type PutFlagDef doesn’t store anything,
but the wrapper MkAction has a string command and an
IsDirected indicator that the action should be applied to a
near cell the player points to.
In contrast, objects have more info specific to each type.
Landmines have a variable explosion rate, timer bombs may
tick, and empty cells do nothing. Also, objects may have an
icon and a string identifier (object type) attached to them. All of
them are certainly empty parametrized ADTs with fields:
data TimerBombDef
(icon :: Symbol)
(objectType :: Symbol)
(turns :: Nat)
type TimerBomb i t
= MkObjectTemplate (TimerBombDef i "timer-bomb" t)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 237
I want the flag command to work for timer bombs by disabling
them. I should add the following instance that transforms and
implements my action:
data MakeActorAction = MakeActorAction
instance
( KnownSymbol ot
) =>
Eval () MakeActorAction
(ObjAct (TimerBombDef i ot p) PutFlagDef)
(IO (ObjectType, ActorAction)) where
eval () _ _ = ...
If no effect should be taken, this method should return a “no
effect” action applied to this type of object:
eval () _ _ = do
let oType = symbolVal $ Proxy @ot #A
let noEffect _ _= pure () #B
pure (oType, noEffect)
#A Querying the type of the object
#B Empty actor action: no effect will be taken
These actions will be evaluated each time some event triggers a
concrete field object (an actor). Let’s, for example, explore the
“disable a bomb” action. Once initiated by the player and the
command flag D (“put flag one cell down”), it will look up a
target cell below the player and apply the following effect to it:
Listing 8.8 Actor action: disabling a bomb
disarmBombEffect :: SystemBus -> ObjectType -> Pos -> GameIO ()
disarmBombEffect sysBus oType pos = do
publishEvent sysBus
$ ObjectRequestEvent oType pos #A
$ SetDisarmed True
#A Send the event that will disarm the target object
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 238
The target cell, an actor, listens for the events. It will see two
events and verify if they’re in the pos position and if the object
type matches oType. A successful check will order the target
actor to process the SetDisarmed event if applicable to its
object. Empty cells will ignore this effect, while landmines and
timer bombs will react accordingly. Events between actors make
them less coupled, even isolated. Actors don’t need to know
about other actors, and field objects do not.
The actor model and domain-level extensibility work together:
interfaces make entities unified yet decoupled, and events allow
actors to interact yet stay isolated. Thanks to the type-level
interfaces and event-driven architecture, we solved the
Expression Problem. We created a flexible, relatively simple
system that obeys all the good design principles: SOLID, low
coupling, and others. If you want to learn more, consider
reading Appendix E, Event-based architecture of the Minefield
game.
8.1.5 Type-level combinatorial eDSLs and lambdas
The eDSLs we’ve developed previously either model a domain
with hierarchically organized notions or embody a script-like
structure closer to imperative, not functional, programming.
We'll explore the land of type-level lambdas and combinators to
complete the picture and obtain another useful tool in eDSL
construction.
Again, there is a showcase project for this: auction. I developed
it long before I started writing the manuscript. You’ll find it in
the repository:
../First-Edition/BookSamples/demo-apps/ptld-auction
This is an eDSL for conducting auctions. There are many of
them. Some start at higher prices and go down, while others do
the opposite. The language lets you define lots, prices, auction
flow, and participant regulations. Besides this domain part,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 239
there is also a combinatorial language for string manipulation
that we’ll consider here without touching the rest.
Can you guess what this script does?
type GreetingsLambda = ConcatL "Hello, " (ConcatR "!" PrintF)
type Greetings =
'[ PrintLine "What is your name?"
, GetLine GreetingsLambda
]
Figure 8.6 Combinatorial language for string manipulation
Greetings here is a list of IAction types; it’s of no interest to
us. Let’s focus on the string concatenation one-liner,
GreetingsLambda. The idea is that GetLine “obtains” a string
from the outside (for example, reads it from the terminal) and
then “passes” it to the ConcatL lambda. There is no explicit
argument in ConcatL. The argument is supposed to be. It’s a
convention, not an encoding. Similarly, the result of ConcatL
will be conventionally passed to ConcatR, and so again with
PrintF. The term “lambda” may not be accurate enough,
though. You can call these functions “combinators” if you prefer.
The combinatorial language for string manipulation can be
extended to do more things. I have, for example, the Both
combinator that splits its imaginary input and distributes it for
two containing lambdas:
type StoreName = WriteRef Symbol "name" #A
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 240
type Greetings2 =
'[ PrintLine "What is your name?"
, GetLine (Both GreetingsLambda StoreName) #B
]
#A Store a value under the “name” key
#B Print the greeting and store the name
Figure 8.7 Both combinator
This split works because every lambda chain has to terminate
with an effect, so we don’t need to return anything from the
inside. WriteRef is an effect that will write its implicit
argument (this time of the Symbol type) into a key-value
storage. The storage should be provided when running. For
example, I can do this in tests and check what values have
been written by the script:
Listing 8.9 Running the script and testing the results
data AsImplAction = AsImplAction #A
it "Combinators test with Both" $ do
ctx <- TestData #B
<$> newIORef Map.empty
<*> pure Map.empty
lines <- eval ctx AsImplAction $ Proxy @Greetings2 #C
lines `shouldBe` #D
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 241
[ "What is your name?"
, "\"Hello, John Doe!\""
]
verifyRef ctx "name" ("John Doe" :: String) #E
#A Verb for actions evaluation
#B Preparing an internal storage (context)
#C Running the script
#D Current implementation returns a list of strings
#E Verifying a variable
There are some tricky parts in how I define and interpret
lambda combinators. It starts from the ILambda interface,
which should have in and out types:
data ILambda inT outT where
LambdaWrapper :: a -> ILambda inT outT
type family MkLambda inT outT a :: ILambda inT outT where
MkLambda inT outT a = LambdaWrapper a
When needed, combinators will concretize. ConcatL is a
lambda that receives Symbol and returns Symbol. Also, it
stores another lambda that receives Symbol with the return
type unspecified.
data ConcatLImpl
(str :: Symbol)
(lam :: ILambda Symbol outT)
type ConcatL
(str :: Symbol)
(lam :: ILambda Symbol outT)
= MkLambda Symbol Symbol (ConcatLImpl str lam)
These input and output types classify what we can use at the
script's type level. These types often don’t match what we use
at runtime. When it’s Symbol at the type level, it will be
String at the value level; for Nat there should be Int, and so
on. This complicates both the language and the evaluation. In
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 242
addition to the Eval type class, I use EvalLambda, which
looks as follows:
class EvalLambda payload rtInT verb noun ret
| verb noun -> ret where
evalLambda :: payload -> rtInT -> verb -> Proxy noun -> ret
It has one more argument rtInT for input types that par the
so from the type level. In payload, I can pass a key-value
storage or provide another context for my interpreters,
depending on their needs. Check out this implementation of
ConcatL (shortened for clarity):
instance ( ... ) =>
EvalLambda ctx String AsImplLambda
(ConcatLImpl str lam) (IO [String]) where
evalLambda ctx val _ _ = do
let lStr = symbolVal $ Proxy @str
evalLambda ctx (lStr ++ val) AsImplLambda $ Proxy @lam
We, therefore, have two layers of operation. At the type level,
we use type-safe combinators with no visible arguments, and at
the mixed level, we pass arguments explicitly, but their type
can be different.
The auction code has even more to unravel. You might be
interested in learning how reading and writing actions work:
type Actions =
'[ ReadRef Int "val1" (WriteRef Int "val2")
, ReadRef Int "val2" (WriteRef Int "val1")
]
There is also another incarnation of the Typed-Untyped design
pattern with Data.Dynamic is used instead of GHC.Any.
StateContext is a key-value storage:
import Dynamic
newtype StateContext = StateContext (IORef (Map Key Dynamic))
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 243
Finally, the extensible auction eDSL has many moving parts
that may give you more hints about organizing such projects. I
invite you to visit the repository and explore it on your own.
For now, this is the end of our journey. We’ve seen a lot and
learned a lot. Let’s celebrate this moment of enlightenment
together.
8.2 Summary
▪ Extensibility involves both interfaces and extension points.
▪ Interfaces simplify and decouple systems but are not
inherently extensibility points.
▪ Extension points can exist without interfaces, often as
hardcoded options needing updates for new
implementations.
▪ Empty parametrized ADTs (eADTs) are explicit and less
complex for domain modeling than promoted ADTs.
▪ One possible approach to type-level interfaces involves
promoting an existential ADT for the interface itself and a
helper type family to wrap implementations.
▪ Empty parametrized ADTs can be used as implementations
of type-level interfaces.
▪ Empty parametrized ADTs and regular promoted ADTs work
together to improve domain modeling. EADTs are used for
extensible parts, and regularly promoted ADTs are used for
customizable parts.
▪ It is possible to consolidate various type classes with only
one universal evaluation mechanism, making the code
more uniform and clear.
▪ Type-level interfaces and the universal evaluation
mechanism can make systems extensible along both verb
and noun extensibility directions, thus effectively solving
the Expression Problem.
▪ Type-level interfaces can also work within event-driven
architecture to improve the decoupling and extensibility of
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 244
the system.
▪ Type-level interfaces enable various eDSL designs,
including lambda-like combinatorial languages.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 245
Conclusion
Complexity itself is an emergent phenomenon. The path to
complexity often involves small, possibly simple pieces that,
once connected, show properties that never existed in any part.
Controlling complexity is recognizing an approach as overly
complex and watching a system's relations and emergent
properties.
Some complexity can be tolerated if justified or unavoidable.
Still, it’s a good idea to try to localize it and hide behind
boundaries so it does not leak into the most important parts of
the system. However, the more complexity we tolerate, the
harder it will hit us in the future, thus causing loss and
problems.
Pragmatic type-level programming is a sharp-edge walking
activity, so we must be cautious with our chosen solutions. I
hope this book has taught you to reason the engineering way
and given you enough tools for future endeavors.
Thank you for reading the book!
Also, I’d be really happy if you read my other book, Functional
Design and Architecture. In some sense, I wrote Pragmatic
Type-Level Design to market it better! It was a challenging
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 246
marketing campaign, but I feel I could bring more value to the
world of functional programming.
Figure X.1 The lifecycle of complexity
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 247
Rosetta Stone
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 248
Appendices
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 249
Appendix A
Typed Forms diagrams
I invented Typed Forms diagrams for a better illustration of
type-level programming. They are not the only visual tool I
invented for my books. In Functional Design and Architecture, I
proposed a less formal yet useful set of diagrams for designing
applications: necessity diagram, components diagram, and
architecture diagram. These have some place in Pragmatic
Type-Level Design, too, although I don’t make an accent on
this. Still, it was evident I needed something else, a visual
language that could enrich the book and help the reader look at
the code from a different angle. I wanted to make something
that could compete with the UML diagrams, and could be
language-agnostic to enable them for many programming
ecosystems. Another consideration was that diagrams should be
more formal, and there should be a possibility to automatically
generate a code from fully formed diagrams. I doubt we’ll ever
see a new interest in the CASE tools, which were arguably not
successful enough, but having such a possibility would still be
nice. We don’t really know how the industry will change with
time, especially in the presence of AI, so I hope the format I
developed here will at least give you aesthetic pleasure.
NOTE I did my best to construct a diagram format that is
language-agnostic, free from ambiguity, expressive
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 250
enough, and low-noise, but Typed Forms v.1.0 are not
perfect, and there can be design flaws.
NOTE Usage of Typed Forms in the book may be
occasionally inconsistent.
A.1 General conventions
The main model for the diagrams is the Haskell’s type system.
If there are discrepancies between type systems from different
languages, specific dialects of Typed Forms can be used.
Round-cornered shapes and sharp-cornered shapes
Round-cornered shapes. Represent type-related notions,
including ordinary types (concrete types that don’t have type
parameters), type aliases, types of types (kinds), and others.
Most of the shapes have rounded corners.
Example: Int, String (ordinary types); * (a “star” kind – a
type of ordinary types).
Figure A.1 Type-related notions
Sharp-cornered shapes. Represent values and value-related
notions.
Example: Animal is an algebraic data type that is shaped with
round corners, and Cat is a value constructor shaped with
sharp corners. Also, name is a Cat’s constructor field
represented by a rectangle, a sharp-cornered shape.
data Animal = Cat
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 251
{ name :: String
}
Figure A.2 Value-related notions
Solid line shapes and dashed line shapes
Solid line shapes are used for notions that can be directly
encoded, such as types, value constructors, type parameters,
and so on.
Dashed line shapes are reserved for kinds (types of types) and
for specific cases.
Arrows and connections
Connection lines without arrows mostly mean “has a relation to”
or “notion has this type/kind.”
Figure A.3 Connection line for a type of field
Arrows from types to types mean “is part of.” In general, arrows
show a “flow” (or “delivery”) of types, not dependency.
Example: the String type is included in the Animal ADT
because Animal contains a field of the String type.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 252
Figure A.4 “Delivery” of a type into another type
Optional elements
There is no obligation to put every possible aspect of a notion
into a diagram. Many aspects are optional, for example, kinds
of types. They can be omitted to preserve space.
However, sometimes important elements need to be hidden. To
indicate this, a special notation is used; see figure A.5.
Figure A.5 Value constructors of the ADT are hidden.
Specific shapes and unknown type-related shapes
Many type-related notions have their own shapes (regular
types, ADTs, type classes, and others). If, however, it’s not
known what exactly notion it is, it should be represented by a
regular round rectangle.
For example, a library exposes the type Map, but it’s not known
if this type is an ADT, a type alias, or something else. It should
be then a rounded rectangle.
Comments
Comments on the diagrams should be represented by grey text
and pointing lines.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 253
A.2 Regular types, pairs, aliases, lists
Regular types
Regular types are represented by round rectangles of arbitrary
sizes, but it’s recommended to use 1-unit and 0.5-unit heights.
Figure A.6 Regular types
Pairs
Pairs are represented by a specific shape containing inner
rectangles for field types separated by a rhombus delimiter
shape.
(Int, String)
Figure A.7 Pair of Int and String
Aliases
Aliases can be represented by separate types that are pointed
by a circle arrow.
A “burger” notation can also represent aliases.
type UserInfo = (Int, String)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 254
Figure A.8 Aliases
Lists
Lists have a special syntax: an extra rectangle over an enclosed
type.
type UserCatalog1 = [(Int, String)]
type UserInfo = (Int, String)
type UserCatalog2 = [UserInfo]
Figure A.9 Lists
A.3 Simple ADTs
Algebraic data types have a specific shape with round corners
(same as for lists which are unnamed half of ADT).
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 255
Value constructors have a specific shape with sharp corners.
Value constructors should be attached to the ADT with a thick
solid line.
Fields should be specified as rectangles with types either
attached as separate rectangles or as a burger.
It’s allowed for value constructors to be stacked or attached to
the ADT directly.
data Animal
= Cat { name :: String }
| Dog { name :: String }
Figure A.10 ADT with stacked value constructors
data Hand = Left | Right
Figure A.11 ADT value constructors attached directly
Fields with no names can be denoted by a type using the
rhombus delimiter.
type Id = Int
type Login = String
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 256
data User = User Id Login
Figure A.12 Field having no name
It’s possible to hide value constructors or fields. A special shape
will indicate that the diagram is incomplete.
data Animal
= Cat { name :: String }
| Dog { name :: String }
Figure A.13 Hidden value constructors and fields
Newtypes
Newtypes is an ADT-like wrapper type for any other type that
has only one value constructor with one field. It is denoted by
the ADT shape, which has a long-dashed line.
newtype LastName = LastName String
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 257
Figure A.14 Newtype
A.4 Parametrized types
Usual parametrized types
Parametrized types have one or many type variables.
Low-height nested rectangles represent type variables. The font
size should be smaller than usual.
Generic (non-specific) type variables are formatted in italics and
have the shape of an ellipse-like rectangle colored grey.
Specified type parameters have a usual rounded white
rectangle shape, normal formatting, and carry the name of the
type.
Examples in Haskell and C++:
Map Int v
template<int K, typename V>
class Map {};
Figure A.15 Parametrized types
Simple syntax for parametrized aliases
Parametrized aliases can have independent variables and can
borrow unspecified type variables. In simple syntax, all type
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 258
variables from the source type should be either specified or
borrowed.
-- Opaque type of unknown structure:
type Map k v1
type IntMap v2 = Map Int v2
Figure A.16 Simple syntax for parametrized aliases
Complex parametrization with possible type variable renamings
should be done with the generics specification syntax.
Abstract generic types
Abstract (opaque) generic types, if no additional information is
known about them, may have a regular shape and italics font.
Figure A.17 Abstract generic type
A.5 Generics specification syntax
For complex cases of type variable specification, a notation of
“delivery” can be used.
The circle-pointed arrow shows the direction of flow of a type
variable. Note that the arrow starts on the parametrized type,
not the type variable.
-- Opaque types of unknown structure:
type Parametrized v
type Specific
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 259
type Alias = Parametrized Specific
Figure A.18 Generics specification syntax
A.6 Type classes and instances
Type classes are represented by the ellipse rectangle with type
variables syntax in grey. Type class names and type variable
names should be italic.
Instances are denoted with a specific sharp-cornered rectangle.
Specification of the types uses the generics specification syntax.
Type classes connect to instances using a dashed arrow. Note
that the arrow starts on the type class itself, not on the type
variable.
data LastName = LastName String
class Print a where
print :: a -> IO ()
instance Print LastName where
print (LastName n) = putStrLn n
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 260
Figure A.19 Type classes, instances, and generics specification
A.7 Kinds
Kinds is a term in Haskell that denotes types of types.
NOTE Kinds are obsolete in Haskell, but they are very
representative, so they are used in the book.
All kinds should have dashed round-cornered shapes.
Types connect to their kinds with a dashed line.
Ordinary types
Ordinary types have the “star” kind.
Figure A.20 “Star” kind
Specific kinds
Specific kinds have specific names.
data UserInfo (userId :: Nat) (login :: Symbol)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 261
Figure A.21 Specific kinds
Type constructors
Type constructors (parametrized types) have complex kinds.
Kinds can be connected together with the grey rhombus shape
to form a complex kind.
data Maybe a = Nothing | Just a
type MaybeAlias = Maybe
Figure A.22 Complex kind
Various kinds are presented in the next figure.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 262
Figure A.23 Various kinds
A.8 Type promotion
In Haskell, types can be promoted one level up. Types become
kinds, ADT value constructors become types of a specific kind.
{-# LANGUAGE DataKinds #-}
data PersonType = Person String String
data UserType = User
{ login :: Symbol
, valid :: Bool
, person :: PersonType
}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 263
Figure A.24 Type promotion
A.9 Type families
A specially shaped round rectangle with a triangle denotes
Haskell’s type families. Open type families will have a white
triangle and closed type families will have a grey triangle.
Figure A.25 Type families
A.10 HKD template
The HKD template from the Higher-Kinded Data design pattern
is denoted with a specific “paper sheet” shape.
Specification of the HKD template may use the generics
specification syntax.
{-# LANGUAGE TypeFamilies #-}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 264
newtype GUID = GUID String
type family TypeSelector f where
TypeSelector Int = Int
TypeSelector GUID = GUID
data UserInfo f = User { userId :: TypeSelector f }
type MyUser = UserInfo Int
Figure A.26 The HKD design pattern
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 265
Appendix B
Existential Fight Club
Existential Fight Club is a set of programs written in various
languages that demonstrate the existentification and
valuefication approaches to dealing with values of distinct types
uniformly. These approaches can be used to avoid heterogenous
collections and keep the code simpler while yet having all the
benefits of type-level programming.
Each program prints the rules of the Fight Club:
1. You do not talk about Fight Club.
2. You DO NOT talk about Fight Club.
3. If someone says stop, goes limp, or taps out, the fight is
over.
The example shows how to organize the rules using available
type-level features that may or may not resemble Haskell’s type
classes.
You’ll find the programs in the book’s repo:
First-Edition/BookSamples/appendices/AppendixB_FightClub
Folder Language Approach
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 266
haskell_exist Haskell type classes,
existentification
haskell_value Haskell type classes, valuefication
rust_exist Rust traits, existentification
rust_value Rust traits, valuefication
cpp_value C++ concepts, valuefication
cpp_oop C++ classes, dynamic
polymorphism
scala2_exist Scala 2 traits, existentification,
implicits
scala2_oop Scala 2 traits, classes, dynamic
polymorphism
B.1 Haskell, existentification
Haskell fully supports type classes and existential types. Due to
its clarity of syntax, Haskell is the most idiomatic language for
demonstrating this approach.
This is a type class for a generic Fight Club rule:
class FightClubRule rule where
explain :: Proxy rule -> String
It doesn’t need a rule as a value, it needs only the type of the
rule represented with a proxy value. The only method of this
type class should provide a text description of the given rule.
Rules are empty ADTs that may possibly be defined
independently, in separate modules, or even projects. Each rule
has an instance of FightClubRule provided with it:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 267
Listing B.1 Three rules
data FirstRule
data SecondRule
data ThirdRule
instance FightClubRule FirstRule where
explain _ = "You do not talk about Fight Club."
instance FightClubRule SecondRule where
explain _ = "You DO NOT talk about Fight Club."
instance FightClubRule ThirdRule where
explain _ = "If someone says stop, goes limp,"
++ " or taps out, the fight is over."
The rules cannot be packed into a homogenous list because,
first, there is no way to construct values:
rules = [ FirstRule #A
, SecondRule
, ThirdRule
]
#A No such values
Second, the types are so distinct that even proxy values can’t
be held together:
rules = [ Proxy @FirstRule #A
, Proxy @SecondRule
, Proxy @ThirdRule
]
#A Compile error
Existential type Secrecy will help to pack the proxy values into
the same list by erasing the information about specific types.
rules :: [Secrecy]
rules = [ Secrecy (Proxy @FirstRule)
, Secrecy (Proxy @SecondRule)
, Secrecy (Proxy @ThirdRule)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 268
The only information that will be preserved with Secrecy is
that all the types have FightClubRule instances:
data Secrecy where #A
Secrecy #B
:: FightClubRule rule
=> Proxy rule
-> Secrecy
#A Existential type (ADT)
#B Existential value constructor
In the code above, whatever type variable rule is, the
Secrecy value constructor will keep it generic and only know
there is an instance of FightClubRule for it. When
pattern-matching, the type variable, the type class and the
specific instance will be available to the subsequent code, so it
will be allowed to call explain there. See the following code:
explainRule :: Secrecy -> IO ()
explainRule (Secrecy proxy) = print (explain proxy) #A
main :: IO ()
main = mapM_ explainRule rules
#A Invoking the instance
B.2 Haskell, valuefication
The valuefied version doesn’t differ much, we just need to
replace the existentialised constructor with a constructor that
stores a lambda.
data Secrecy where
Secrecy :: (() -> String) -> Secrecy
We then simply wrap the explain method into it and effectively
erase the information about the type class:
makeValuefied :: FightClubRule rule => Proxy rule -> Secrecy
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 269
makeValuefied proxy = Secrecy (\_ -> explain proxy)
Invoking this lambda is now trivial, no need to show it here.
B.3 Rust, existentification
Rust supports type classes in the form of traits. For existential
typing, it employs dynamic objects and dynamic dispatch. It’s
interesting that both combined, traits and dynamic dispatch in
Rust can do everything Java-like interfaces do. This includes the
ability unavailable to type classes: it’s possible to return an
object of a trait from a method of that trait, similar to how an
object of an interface can be returned as a result of some
method. In general, Rust’s type system resembles a lot of
features from Haskell’s and has some extra functionality for
doing OOP.
The trait for the Fight Club rule doesn’t rely on any generic type
variables (so we conclude it will use dynamic dispatch):
trait FightClubRule {
fn explain(&self) -> String;
}
Rules are empty structs (they have no fields):
struct FirstRule;
struct SecondRule;
struct ThirdRule;
Implementing FightClubRule for these rules is quite similar
to Haskell’s instances. Here is one:
impl FightClubRule for FirstRule {
fn explain(&self) -> String {
"You do not talk about Fight Club.".to_string()
}
}
Like in Haskell, rule structures are completely independent.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 270
The next piece of the solution is the existential Secrecy struct.
Rust has its own approach to defining structures with methods
(known as classes in C# and Java). In Rust, the definitions of a
struct and its methods are separate. The struct may have
fields:
struct Secrecy {
rule: Box<dyn FightClubRule>, #A
}
#A The rule field
The rule field has a type Box – a standard wrapping type in
Rust that moves a static type from stack to heap making it
dynamic. This time, it will keep dynamic objects with the
FightClubRule trait. This means it will be possible to do
dynamic dispatch of the specific types and call the methods of
the trait. In other words, it’s an existential now.
Constructing the value of Secrecy will need the following
implementation:
impl Secrecy {
fn new<R: 'static + FightClubRule>(rule: R) -> Self { #A
Secrecy { #B
rule: Box::new(rule), #C
}
}
fn explain(&self) -> String { #D
self.rule.explain()
}
}
#A Constructor of the structure
#B The act of constructing
#C Making a dynamic object from the static rule and storing the
pointer
#D Helper method to access the field
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 271
Rust’s syntax for creating objects is busier than in Haskell. Here
is the legend:
new - constructor of the structure
R - generic type variable
R: - requirements for the type variable
'static - 1st requirement for R to be static (having
the longest lifetime possible)
+ FightClubRule - 2nd requirement to implement the trait
There is some trickery with lifetimes and the borrow checker. In
this code, FirstRule, SecondRule, and ThirdRule are
static things, they live as long as the application lives. When
doing Box::new(rule), we move this variable from stack to
heap giving it some extra lifetime compared to the scope of this
constructor. The new boxed object exclusively belongs to the
Secrecy wrapper and thus can be kept for a longer time.
We now have everything to compose the program with rules:
fn main() {
let rules: Vec<Secrecy> = vec![
Secrecy::new(FirstRule), #A
Secrecy::new(SecondRule),
Secrecy::new(ThirdRule),
];
for rule in rules { #B
println!("{}", rule.explain()); #C
}
}
#A Wrapping rule types and erasing type information
#B Traversing the Secrecy wrappers
#C Invoking the Secrecy method which then will invoke the trait
B.4 Rust, valuefication
We can store not the rule pointing to the trait but rather a
lambda (they call it closure in Rust) with no information about
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 272
the trait. This will be a simple lambda that doesn’t mutate
anything:
struct Secrecy {
rule_f: Box<dyn Fn() -> String>,
}
Now we need to write a constructor that would create a lambda
from the rule. To do this properly with respect to the borrow
checker, we should first box the passed rule static variable and
thus move it from its stack displacement to heap:
let boxed_rule = Box::new(rule);
Then we can capture this new dynamic variable in a closure. We
should move the underlying dynamic object from this scope into
the scope of the Secrecy structure. We do this with the move
semantics in Rust. This is the complete code of the structure:
impl Secrecy {
fn new<R: 'static + FightClubRule>(rule: R) -> Self {
let boxed_rule = Box::new(rule); #A
Secrecy {
rule_f: Box::new(move || boxed_rule.explain()), #B
}
}
fn explain(&self) -> String {
(self.rule_f)() #
}
}
#A Boxing a static variable
#B Constructing a closure and moving from this scope
#C Invoking the field that stores a lambda
The rest of the code is the same.
B.5 C++, object-oriented variant
Rust has been born as another “C++ killer,” among a few
others: D, Julia, maybe Dart. It seems we really have serial
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 273
killers here, meaning it’s a series of them aiming at one victim.
But Rust looks more successful among them and really brings
something to the table of language murdering. One of its killer
features is the consistency of approaches if we compare Rust to
C++. This contrast between the two serves as a constructive
critique of the C++ language, which arguably became the most
complex language ever, where each version tries to be even
more complex than before. With that, we can’t deny that C++
has its merits. There are, for example, features for type-level
programming that are interesting on their own, and we’ll
consider several of them here.
We’ll first examine how to implement the task with classic OOP.
It’s not type-level programming, and I assume we all know how
to do this, but it may be a useful exercise anyway.
This will be an interface for rules:
class FightClubRule {
public:
virtual ~FightClubRule() = default;
virtual string explain() const = 0; #A
};
#A Pure virtual function
This interface is closer to Java and C# interfaces than to Rust’s
traits. We cannot have a value of this class directly because it’s
abstract (one or more methods is a pure virtual function), so
there should be dependent classes for rules that implement this
interface. This is the first rule:
class FirstRule : public FightClubRule { #A
public:
string explain() const override #B
{
return "You do not talk about Fight Club.";
}
};
#A Inheritance from FightClubRule
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 274
#B Implementing the method
Now, we need to put the specific rules into an array. In Rust
and C++, we could not do this with one extra step, which is
called the Secrecy existential wrapper. This time we don’t
really need it because the rule types can be used directly as
things that obey the interface, for example, they can be put
into an array (vector) of pointers (shared_ptr<T>) to
FightClubRule:
vector<shared_ptr<FightClubRule>> rules_direct = {
make_shared<FirstRule>(), #A
make_shared<SecondRule>(),
make_shared<ThirdRule>()
};
for (const auto &rule : rules_direct) {
cout #B
<< rule->explain() #1
<< endl;
}
#A Making dynamic objects of the specified type and implicitly
casting them to the supertype
#B C++’s way to print strings
#1 Dynamic dispatch
Notice how we call the method at 1: we access the pointer to
the object, not the object itself, but C++ relays our call to the
specific rule type using dynamic dispatch.
If we still may want to complicate our lives with extra
indirection, we can. We just provide a Secrecy class that holds
the same dynamic pointer and unifies the type of rules:
class Secrecy {
shared_ptr<FightClubRule> rule;
public:
template <typename T>
Secrecy(shared_ptr<T> r) #A
: rule(r) {} #B
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 275
string explain() const
{
return rule->explain();
}
};
#A Object constructor for the class of the generic type T
#B Filling the rule field with the passed pointer
You can easily imagine the usage. It’s mostly the same as for
the direct case, but there is a slight difference in how we invoke
the methods. We do this on the objects now, not on the
pointers now.
vector<Secrecy> rules = {
Secrecy(make_shared<FirstRule>()),
Secrecy(make_shared<SecondRule>()),
Secrecy(make_shared<ThirdRule>())};
for (const auto &rule : rules)
{
cout << rule.explain() << endl;
}
B.6 C++, valuefication
C++ has supported concepts since its C++20 version. Some
might argue that concepts in C++ are not type classes, just as
some might argue that templates in C++ are not generics, but
this argument is very academic. To these debaters, a pizza isn't
a pizza unless it's made with San Marzano tomatoes and buffalo
mozzarella, and baked in a wood-fired oven from Naples. For
others, "authenticity" doesn’t play much of a role as long as it
tastes good and satisfies their hunger. Therefore, from the
pragmatic perspective, concepts are type classes, and
templates are generics with certain differences that are
important but don’t ruin the analogy.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 276
The following code shows how we declare a concept in C++. We
use the template syntax for providing a generic type variable T
and then put some requirements into it:
template<typename T>
concept FightClubRule = requires(T rule) {
{
rule.explain() #A
} -> same_as<string>; #B
};
#A There should be a method explain()
#B The method should return a string
Rule types are now structs with no inheritance. By default,
everything we put into structs is public, while everything we put
into classes is private, – unless said opposite.
struct FirstRule {
string explain() {
return "You do not talk about Fight Club.";
}
};
There will be no explicit instances of FightClubRule for these
structs, although the structures satisfy all the requirements.
Instead, we demand type class constraint when wrapping a
specific rule into the Secrecy valuefied storage. We use
valuefication to avoid many complications of C++, although
some kind of existentialis is yet possible.
struct Secrecy {
function<string()> _ruleF; #A
Secrecy() = default;
Secrecy(FightClubRule auto&& rule) { #B
_ruleF = [&]() { return rule.explain(); }; #C
}
string explain() {
return _ruleF();
}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 277
};
#A First-class function with no arguments
#B Object constructor with the type class constraint
#C Storing a lambda into the _ruleF field
This C++ variant has one significant difference from others
discussed before. It doesn’t control the lifetime of the rule
objects. Previously, shared_ptr<T> was responsible for the
destruction of objects, it’s Box. Here, the rules should be
available upfront and should be created on a stack like this:
auto rule1 = FirstRule{};
auto rule2 = SecondRule{};
auto rule3 = ThirdRule{};
vector<Secrecy> rules = {
Secrecy(rule1),
Secrecy(rule2),
Secrecy(rule3)
};
for (auto &&rule : rules)
cout << rule.explain() << endl;
Memory management in C++ is much more complicated than in
Rust, not saying about managed languages with garbage
collectors.
B.7 Scala 2, existentification with implicits
In Scala 2, we can mimic the style of Haskell’s type classes with
traits and implicits. Please note that the approach is a bit tricky
and provides a suboptimal developer experience, so in Scala 3,
implicits have been reworked and arguably improved.
The trait can be defined easily:
trait FightClubRule[R] {
def explain(rule: R): String
}
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 278
Scala prefers square brackets for defining generics, hence the R
type variable for a specific rule. This will be a specific rule type
that will allow Scala to select a specific explain function.
There should be an existential wrapper, the Secrecy
parametrized class, that relies on implicits. The constructor of
this class will have two special arguments:
class Secrecy[R] #A
(val ruleInstance: R) #B
(implicit val fightClubRule: FightClubRule[R]) { #C
def explain: String =
fightClubRule.explain(ruleInstance) #D
}
#A Parametrized class
#B First argument: a value of a specific rule type
#C Second implicit argument: a value of the trait
#D Doing the trait-related dispatch for the specific rule
Defining rules also requires some implicit gymnastics.
object Rules {
case object FirstRule
implicit val firstRuleInstance: FightClubRule[FirstRule.type]=
new FightClubRule[FirstRule.type] {
def explain(rule: FirstRule.type): String =
"You do not talk about Fight Club."
}
}
Honestly, I don’t fully understand how this works. We have a
specific FirstRule type similar to empty ADTs in Haskell.
Using it, we then specify an implicit value of type
FightClubRule[FirstRule.type]. Then we put a new
object of this specific type into the implicit value. It seems this
implicit value will be able to provide a correct explanation when
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 279
called from Secrecy. Here is how we create and use the
storage of wrapped rules:
object Main extends App {
val rules: List[Secrecy[_]] = List(
new Secrecy(Rules.FirstRule),
new Secrecy(Rules.SecondRule),
new Secrecy(Rules.ThirdRule)
)
rules.foreach { secrecy =>
println(secrecy.explain)
}
}
The Rules object here works as a module providing us with the
scoping possibilities. We can certainly have all specific rules
defined separately in own scopes because they are
independent.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 280
Appendix C
The Mythologized Correctness
Type safety and correctness are the two holy cows of the
functional programming world.
The treatment of these terms is greatly influenced by scientific
papers in which correctness appears as a phenomenon to
investigate. These papers paint correctness as a highly
mechanistical condition that can be achieved by building a
rigorous type-level system of proofs which then can be used to
ensure various invariants in the code. It’s not uncommon for
these approaches to implement huge chunks of Math such as
Category Theory, Homotopy Type Theory, and Abstract Algebra,
to solely provide an environment for formal verification of some
specific properties of the code.
With that, the mainstream understanding of correctness goes at
odds with the scientific one. Formal verification of correctness
may be indeed useful in certain domains (like aerospace,
nuclear, and financial systems), but in most cases, it’s too
narrow, too expensive, and missing the point. Formal
verification, as it’s being pushed from Computer Science, deals
with pure models and cannot address the issues of the impure
and dirty real world.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 281
NOTE Types, type theory, type applications, proofs, and
everything around that is a huge, horizonless theme. If
you are interested in a more scientific background, you
can read Types and Programming Languages by
Benjamin Pierce. This book treats types as Math objects
and builds a complete theory of types. In turn,
Type-Driven Development by Edwin Brady, the creator of
Idris, may be more useful to have a deeper look at
advanced type-level development. In contrast to Haskell,
Idris has a good implementation of dependent types
which gives you even more tools for reasoning about
your programs.
LINK Edwin Brady, Type-Driven Development with Idris
https://www.manning.com/books/type-driven-developme
nt-with-idris
LINK Benjamin Pierce, Types and Programming
Languages
https://www.cis.upenn.edu/~bcpierce/tapl/
C.1 Type safety
Type safety can be achieved using phantom types carried
around, by providing additional type variables, parameterizing
types with other types, specifying conversion type classes,
limiting types with type constraints… Expanding all the tricks
and providing samples would require a whole new book because
type safety seems to be one of the main concerns of Haskell
libraries. We’ll try at least to highlight the main ideas and points
and cast some critique of type safety as it’s understood by
haskellers.
C.1.1 Generic type safety
We’ll first take a look at the lens library by Edward Kmett. It
helps us to navigate and reconstruct complex data structures,
be it vectors, lists, maps, trees, or any algebraic data types
having an arbitrary internal structure. It also has a very tricky
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 282
internal machinery that relies on type-level features aimed to
emphasize a safe and correct composition of lenses.
type Lens s t a b = forall f.Functor f => (a -> f b) -> s -> f t
Let’s see how this works. The following are two ADTs organized
hierarchically:
data Inner = Inner { _intField :: Int, _doubleField :: Double }
data Outer = Outer { _innerField :: Inner, _strField :: String }
Figure C.1 Two ADTs organized hierarchically
The following code is a sample value of the two:
myADT :: Outer
myADT = Outer
{ _innerField = Inner
{ _intField = 10
, _doubleField = 5.5
}
, _strField = "just a string"
}
We also need to construct lenses for our types. We do this with
the help of TemplateHaskell (compile-time macros):
{-# LANGUAGE TemplateHaskell #-}
import Control.Lens.TH (makeLenses)
-- Inner and Outer ADTs should be here followed by makeLenses --
makeLenses ''Inner
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 283
makeLenses ''Outer
Double ticks here mean this type should be deconstructed by
the compiler, and then passed to the makeLenses
TemplateHaskell function to be further reconstructed as lenses.
We now have these basic functions:
import Control.Lens (Lens')
innerFieldLens :: Lens' Outer Inner
intFieldLens :: Lens' Inner Int
doubleFieldLens :: Lens' Inner Double
We can now produce more complex lenses with just functional
composition:
import Control.Lens ((^.))
outerToIntLens :: Lens' Outer Int #A
outerToIntLens = innerFieldLens . intFieldLens
myInnerInt :: Int
myInnerInt = myADT ^. outerToIntLens #B
#A Compound lens
#B Extracting the internal field
So we just queried the internal integer field of myADT with a
compound lens. Lenses are twice typed: source user-defined
data type and a result data type, and they can be connected if
the two match. We don’t want to occasionally get a Double
value when it should be Int if we misplace a lens or a field.
Only a valid composition of lenses is possible. This code is fine:
innerFieldLens . intFieldLens
But this code won’t compile:
intFieldLens . doubleFieldLens
There is no
point in jumping between _intField and
_doubleField of the same Inner structure. Plumbers know
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 284
this feeling: all the pipes and fittings should match, and here
we just have two mutually exclusive plugs.
To prevent invalid usages, the library utilizes the full power of
advanced type features along with a massive invasion of
Category Theory. The library comes with two costs. First, it
contains dozens and dozens of abstract lens combinators that
constitute their own eDSL. Second, it is quite complicated
internally, which sometimes results in cryptic compiler
messages.
It has, for example, a simplified type for lenses because the
base one has too many type variables that address a very
general case that is not always needed:
type Lens s t a b = forall f.Functor f => (a -> f b) -> s -> f t
type Lens' s a = Lens s s a a
It also heavily relies on many mathematical notions such as
profunctors, Kan extensions, semigroupoids and others, all of
which are embedded into the library making it highly abstract
and difficult to grasp.
type IndexedTraversal i s t a b =
forall p f. (Indexable i p, Applicative f)
=> p a (f b) -> s -> f t
Guaranteeing type safety through a massive invasion of Math
and the abundance of generic type transformations seems to be
a dominant approach in the Haskell world. I’d call it “generic
type safety”. Can you imagine a fundamentally different
approach? There is one, at least. I’d call it “descriptive type
safety”.
C.1.2 Descriptive type safety
The development process has two main activities: implementing
the code that relates either to infrastructure or a business
domain. Sometimes we really want to focus on the domain part
without being distracted by side activities that may be
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 285
important for the whole system to function but do not represent
any commercial value per se. When such a requirement occurs,
it’s better to have a domain-specific language that could enable
fast and convenient domain modeling with no need to touch
unrelated code. Sometimes the domains are so sophisticated
and error-prone that we’d like to make domain modeling as
safe as possible. Therefore we try to bring as much type safety
into these DSLs to have some sort of correctness naturally
embedded into them. The very structure of such a DSL will
enforce type safety and correctness, but, in contrast to generic
type safety, this one will try to hide behind the domain notions
and be as natural as possible.
Type-level cellular automata eDSLs developed for this book
were such. They have several aspects that prevent invalid type
usage and meaningless cellular rules. For example, the newtype
approach finely works both on the value and type levels:
newtype DefaultState = DefState StateIdxNat
Values of this type can only be used in a specific place of
cellular rules:
type GoLStep = 'Step ('DefState D) '[ ]
The same can be done for StateIdxNat and other notions that
might occasionally be confused with others:
newtype StateIdxNat = IdxNat Nat
newtype StateNameSymb = NameSymb Symbol
If we want more implicit type safety, we introduce more explicit
descriptive types into a domain model for every possible notion
or interaction. Below you see a fragment of an auction script
from the ptld-auction demo application. This code describes
three lots to be traded and is written with the advanced
domain-oriented type-level language for auctions.
type WorldArtsInfo = Info "World arts" "UK Bank"
type WorldArtsLots = Lots
'[ Lot "101" "Dali artwork" PayloadLot1 (Currency GBP) UKOnly
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 286
, Lot "202" "Chinese vase" PayloadLot2 (Currency USD) UKAndUS
, Lot "303" "Ancient mechanism" PayloadLot3 (Currency USD)
NoCensorship
]
The language has many extension points so that the developers
can provide their types and values such as specific currencies
and auction algorithms, and several embedded security
mechanisms help with type safety, most of which are
descriptive in the domain of auctions.
C.2 Technical correctness
By further deconstructing the term “correctness” as it’s used in
various scientific papers, we can, first, conclude it studied a
technically achievable state (“prove what can be proven no
matter if makes any sense”), and second, there are four major
areas of this research:
● correctness of data structures and algorithms
● correctness of data models
● correctness of languages (both general-purpose
programming languages and specific domain-oriented
ones)
● formal verification and mathematical proofs in code
In this section, I’m going to demonstrate that technical
correctness, while being a holy grail of research and
development, often neglects what is important: the actual
meaning of things that come from business domains.
C.2.1 Correctness of data structures and algorithms
Can you guess what is the most demonstrated code sample in
the literature about correctness-enforcing data structures? I
think it's “vectors with a statically checked size” (or else
“length-indexed vectors”). I can’t resist standing next to those
materials! Listing C.1 demonstrates the usage of such vectors
from an imaginary-indexed-vector library. This is a data
type explicitly designed to store the first names as a 10-letter
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 287
vector, and there is a function, getFirstName, to get the name
from the console and transform it into this data type:
Listing C.1 Sample of usage of indexed vectors
import "imaginary-indexed-vector" Data.Vector.Indexed
(Vector, ParseError (..), fromString)
type FirstName = Vector 10 Char #A
getFirstName :: IO (Either ParseError FirstName)
getFirstName = do
putStr "Enter your first name: "
nameStr <- getLine
pure (fromString nameStr) #B
#A FirstName is a vector of 10 characters. 10 is a type.
#B Magic function for converting a string to a vector. May fail.
We can call it to get the name and print it with greetings:
greeting :: IO ()
greeting = do
eName <- getFirstName
let msg = case eName of
Left EmptyString -> "Name can't be empty."
Left OutOfBoundaries -> "Name exceeds 10 chars."
Right name -> "Hello, " ++ show name ++ "!"
print msg
We don’t know that much about the Vector type, but we can
suppose it’s like a regular array but with additional guarantees
of correctness when used in some transformations. We explicitly
specify the number of characters in this vector, and the library
should enforce this contract when doing transformations over
those values. We obtain such a value by parsing it from a
regular string with the magic function fromString that, we
can see from the code, may fail. If it succeeds, we must have a
correct vector of chars, right?
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 288
Let’s now have a Person type with two fields: FirstName and
LastName, both are vectors with strictly checked sizes. We
provide two functions to obtain this data:
type FirstName = Vector 10 Char
type LastName = Vector 20 Char
getFirstName :: IO (Either ParseError FirstName)
getLastName :: IO (Either ParseError LastName)
Using the join function from the same library, we could join
the two into a vector of the combined length:
import "imaginary-indexed-vector" Data.Vector.Indexed (mergeE)
type FullName = Vector 30 Char
greeting :: IO ()
greeting = do
eFirstName <- getFirstName
eLastName <- getLastName
let eFullName :: FullName = merge <$> eFirstName <*> eLastName
print eFullName
Now you may already have a feeling that something is strange
here. First and foremost, what do we mean when we say this?
type FirstName = Vector 10 Char
type LastName = Vector 20 Char
type FullName = Vector 30 Char
Do we mean that our FirstName should be exactly 10
characters long, and LastName should be exactly 20 characters
long? So if they are combined, they give a vector of 30
characters? But how about the space between the names? It’s
not provided here. And what happens when a shorter name is
entered? Running the code may lead to this output:
Enter your first name: John
Enter your last name: Doe
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 289
"Hello, John Doe !"
That’s silly. To satisfy the requirement of having statically sized
vectors, the fromString function padded our values with
spaces. This is what is supposed to be “correct”. After entering
our data, we’re getting two “correct” vectors of sizes 10 and 20,
and after merging them we’re getting a “correct” vector of size
30. But this is not what we want. Pity John Doe who is quite
frustrated because of this.
It’s very easy to think that the correct-by-design interface to
some algorithmic libraries is everything we need. We might also
be tricked by the smartness of the library itself and start
believing that we’re completely protected from mistakes. For
example, take a look at the merge function from this library,
and don’t mind the type-level magic if it’s unfamiliar to you:
merge
:: forall n m z a
. z ~ (n + m)
=> Vector (n :: Nat) a
-> Vector (m :: Nat) a
-> Vector (z :: Nat) a
There is one more function, splitAt, that looks even more
complicated:
splitAt
:: forall n idx z a
. z ~ (n - idx)
=> (idx <=? n) ~ 'True
=> Proxy (idx :: Nat)
-> Vector (n :: Nat) a
-> (Vector (idx :: Nat) a, Vector (z :: Nat) a)
Here, the merge algorithm will always preserve the lengths of
the two vectors being combined into a single one, and splitAt
will provide the opposite guarantees to what the merge function
does. This might be very helpful in other pure algorithms such
as sorting and searching for which nothing beyond pure data
should be accounted for, but when it comes to dealing with raw
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 290
impure world, we’ll encounter much more difficult problems that
can’t be completely guarded with either type-level logic.
Correctness of data structures and algorithms doesn’t
imply correctness of meaning in a particular
business-related code.
C.2.2 Correctness of data models
We’re now climbing higher, from data structures to data
models. Data models rely on data structures such as lists and
strings but can have a less abstract and more domain-related
meaning. Let’s see how claiming technical correctness for a
data model cannot guarantee the complete correctness of the
values of that data model.
Going further with the greetings example, we might want to
convert it into an online login form with boxes for the name and
password. We also want to stay type-level to have more tools
for proving correctness. Luckily for us, we found the
imaginary-gui library that allows us to build QML (Qt Markup
Language) forms fully on the type level and then run them
directly from Haskell. The library provides various primitives for
UI components, thus forming a type-level eDSL. The library also
has a feature to convert the type-level forms into the actual
QML definitions so they can be reused in other applications.
We need a component for name and surname with two fields
adjacent to each other. In the library, we see the following:
-- | It’s a TextBox.
data TextBox (n :: Nat) (str :: Symbol)
It’s not clear what these n and str fields mean, so we have a
full right to assume that n is the maximum number of symbols
in a textbox, and str is a tooltip text that appears on hovering
over the component. Another component, RowLayout, will help
us to keep the two together.
-- "imaginary-gui":
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 291
-- | It's a RowLayout.
data RowLayout (items :: [*])
Given this, we write the following code:
type FirstName = TextBox 10 "Enter your first name"
type LastName = TextBox 20 "Enter your last name"
type NameForm = ColumnLayout
'[ FirstName
, LastName
]
The library tells us that this code compiles, so we must be on
the right path. We now make a form for a password that
contains NameForm and one additional text box organized
vertically with ColumnLayout:
type PasswordForm = ColumnLayout
'[ NameForm
, TextBox 24 "Enter your password"
]
This compiles too, but unfortunately, the resulting form we see
in the application doesn’t do what we expected. See figure C.2:
Figure C.2 QML form with three text labels
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 292
There are no text edit controls here, only labels with text.
Examining the generated .qml file reveals why:
Listing C.2 Corresponding QML representation
import QtQuick 2.7
import QtQuick.Controls 2.3
import QtQuick.Layouts 1
ColumnLayout {
RowLayout {
Text {
text: "Enter your first name"
font.pointSize: 10
}
Text {
text: "Enter your last name"
font.pointSize: 20
}
}
Text {
text: "Enter your password"
font.pointSize: 24
}
}
Due to the lack of documentation and knowledge, we
misinterpreted the TextBox entity. It produces a static text
component with the number n responsible for the font size.
What we needed is TextField from imaginary-gui which
could give us the same named text input box in QML.
-- | It's a TextField.
data TextField (cid :: Symbol) (cap :: Symbol)
Still, given this documentation, it’s not yet clear what
parameters the component expects. We can feed it by
something and get a finely compiling and running application,
but unless we know what cid and cap are responsible for, we
can’t be sure our form does what we want. (Hint: cid is for a
variable id, and cap is for a caption text.)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 293
Structural correctness of data models doesn’t imply
correctness of meaning in a particular business context.
C.2.3 Correctness of languages
“Our language embraces correctness, and this is why you
should use it!”
I believe I’ve seen such a premise in some marketing materials.
Keeping aside that it’s marketing, can this be true? What do
those people try to communicate and in what form do these
languages embrace correctness? Is it about the correctness of
the language itself that has no further implications on the code
and we are just invited to appreciate the language? Or maybe
this correctness somehow eliminates the possibility of writing
the wrong code?
One of the most explicit examples is Michelson, a language for
the Tezos blockchain. You can see the following claim on its
front page:
Michelson is a stack-based, low-level programming
language crafted for the development of smart contracts
on the Tezos blockchain. It is designed for security and
precision, with a strong, static type system. While
Michelson incorporates elements inspired by functional
programming, such as immutability and deterministic
computation, its primary architecture is stack-oriented,
making it tailored for formal verification processes.
LINK Official page of the Michelson language
https://www.michelson.org/
I don’t know what is meant by “security” and “precision”, but
when I see “formal verification”, I see “mathematically proven
technical correctness”. I see a strong belief that once something
is proven to be correct, it’s meaningful and is actually correct in
our human sense. The pursuit of the impossibility of writing
invalid code has indeed limited the surface of possible mistakes,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 294
but this surface will never be zero as long as there is always a
touch of real meaning that we bring from our world. It’s quite
simple to claim a language or a code in this language is proven
to be correct, but it’s equally simple to prove some garbage
from the business point of view.
I’m going to support my point with an example. This will be a
domain-specific language, not a general-purpose one, but
everything said here can be applied to both. It won’t be static
verification per se, there will be no specifications to point out
what should be proven, and there will be no explicit proofs of
something. We’ll just write some Haskell code and utilize its
strong type system to some degree. But the difference is not
that big. Formal verification folks even claim: “a proof is a
program; the formula it proves is a type for the program" (from
the mathematical philosophy of the Curry-Howard
correspondence), and often attribute this property to all or
some functional languages including Haskell. In this logic, our
program will be proof of some premises, while unspoken.
DSLs are mostly used for an easier description of some
business domains. That’s a great tool, and it helps write a
better business logic code by prohibiting unwanted
constructions. Still, you can't get rid of two big traps essential
to our reality: logical errors and domain misunderstanding.
The following embedded language simulates an API of financial
service for money operations.
type Wallet = String
type Currency = String
type Amount = Int
data WalletAPI where
AndThen :: WalletAPI -> WalletAPI -> WalletAPI
Withdraw :: Wallet -> Currency -> Amount -> WalletAPI
Deposit :: Wallet -> Currency -> Amount -> WalletAPI
This language allows to write sequential scenarios. WalletAPI
connects two scenarios in WalletAPI which in turn can be
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 295
composed of Withdraw and Deposit instructions. It’s
supposed that scenarios will be later interpreted into real
actions. A typical money transferring transaction would look like
this:
Listing C.3 Transfer money scenario
transferMoney :: WalletAPI
transferMoney = let
from = "Wallet1"
to = "Wallet2"
currency = "EUR"
amount = 1000
in (Withdraw from currency amount)
`AndThen` (Deposit to currency amount)
This is a valid scenario, so we can’t say that our WalletAPI
language is absolutely incorrect. Achieving our business goals
with it is definitely possible. Yet, it’s quite easy to make silly
mistakes that the design of the language doesn’t prevent
anyhow. Here are some:
● Variables misplacement. Wallets from and to are
swapped incorrectly:
(Withdraw to currency amount)
`AndThen` (Deposit from currency amount)
● Double usage. Wallet from is used twice (an often
copy-paste bug):
(Withdraw from currency amount)
`AndThen` (Deposit from currency amount)
● Units mismatch. These currencies don’t match:
(Withdraw from "EUR" amount)
`AndThen` (Deposit to "USD" amount)
● Amounts mismatch. The deposited sum is not the same
as a withdrawn sum:
(Withdraw from currency amount)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 296
`AndThen` (Deposit to currency 100)
● Values misplacement. Messed up currency and wallets as
they have the same type String:
(Withdraw currency from amount)
`AndThen` (Deposit currency to amount)
● Double spend. Withdrawal is done twice by a mistake:
(Withdraw from currency amount
`AndThen` Withdraw from currency amount)
`AndThen` (Deposit to currency amount)
The eDSL is flawed, yes, but is not incorrect. We just need to be
more careful to avoid bugs, pretty much as C++ developers
step carefully to avoid numerous dangers of their language. For
C++ and WalletAPI that’s a risk but not catastrophe, although
I’m not quite sure about C++. Still, there is a low-hanging fruit
in making the eDSL more safe.
Let’s introduce a type of currency and amount. We’ll call it
TransactionToken:
data TransactionToken = TransactionToken Currency Amount
The Withdraw “method” should now “return” this token, and
the Deposit one should accept it. The token represents a data
dependency between the two, and the AndThen method will
utilize it to only allow connecting the two that match. To encode
these considerations, we should make the type WalletAPI a
parametrized GADT and update the methods accordingly:
Listing C.4 Improved design with GADT
type Result a = Either String a
data WalletAPI a where
Withdraw :: Wallet -> Currency -> Amount
-> WalletAPI (Result TransactionToken)
Deposit :: Wallet -> TransactionToken
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 297
-> WalletAPI (Result ())
AndThen :: forall b
. WalletAPI (Result a)
-> (a -> WalletAPI (Result b))
-> WalletAPI (Result b)
Here, the Withdraw value constructor will carry on three values
(Wallet, Currency, Amount), and will always be of a
compound type WalletAPI (Result TransactionToken).
The Result type should also indicate that each operation may
fail with an error.
The GADT-based design eliminates the very possibility of double
withdrawing. The compiler will complain that
TransactionToken and () types mismatch for the following
sample:
Listing C.5 Invalid double withdrawing
invalidDoubleWithdraw
:: Wallet
-> Wallet -> Currency -> Amount -> WalletAPI (Result ())
invalidDoubleWithdraw from to currency amount =
(Withdraw from currency amount #A
`AndThen` (\_ -> Withdraw from currency amount #B
`AndThen` (\token -> Deposit to token)
)
)
`AndThen` (\token -> Deposit to token) #C
#A 1st withdraw
#B 2nd withdraw
#C Type mismatch
The new eDSL leaves less room for logical mistakes, but still,
it’s not perfect. It doesn’t control all other aspects of our code,
like variables:
from = "Wallet1"
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 298
to = "Wallet1" -- wallets the same
currency = "KLINGON DOLLAR" -- extraterrestrial currency
amount = 10000000000000000000 -- too big number
The same stands for any eDSL, any DSL, and any
general-purpose language. We are suggested to model our
business domains by using abstracted, unrelated syntactic
constructions and elements a language has. The proven
correctness of it and its foundations don’t have anything to do
with the correctness of a program we’re writing. A nicely
designed language helps with that; a badly designed language
sabotages that, but producing meaningful code is always a
developer’s responsibility. We can’t avoid injecting our own
meanings into the code, and this is where the main challenge
occurs. No matter how appealing the marketing of correctness
is, there always will be the same problem:
Correctness of a language doesn’t imply automatic
correctness of the logic.
C.3 Conclusion
▪ Real software can never become absolutely correct due to
the impurity and complexity of the real world.
▪ Software becomes obsolete much faster than it becomes
correct.
▪ Correctness is never the final goal of our work. Working
and useful software is.
▪ Adopting some practices of correct software is still a good
idea, but writing programs to solely have proofs and
ignoring the business part means doing Math, not Software
Engineering.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 299
Appendix D
Extensible value definition
model in the Zeplrog project
The task we’ll be talking about occurs frequently when
designing domain-specific languages. If a DSL allows
calculations, or it’s imperative in some sense, we may realize
the need for variables. However, DSLs are not programming
languages, and we can’t enjoy the value definition syntax and
semantics provided by language creators. We should implement
it ourselves. This means we need a custom DSL-targeted type
system that is better type-safe and extensible. Depending on
the host language, this might be a challenging problem because
type-safety and extensibility don’t come for free. Static type
systems in most languages are often tricky and mind-bending,
so the price of implementing such a system may be high.
D.1 Trichotomy: extensibility, type safety, and
simplicity
I ran into this problem in the Andromeda project, a showcase
project for my book Functional Design and Architecture.
Andromeda is a spaceship control software. It offers an
embedded interpretable value-level DSL for defining scripts that
control the subsystems of a particular space vehicle and make it
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 300
fly. The following snippet is an example; it interacts with a
thruster device, reads its torque property, and calculates a
momentum angular impulse:
getThrusterAngularImpulse
:: Controller -> Mass -> LogicControl AngularImpulse
getThrusterAngularImpulse ctrl mass = do
eMbTorqueProp <- getProperty ctrl "torque" []
torque <- validateTorque eMbTorqueProp
calcAngularImpulse mass torque
Space travel requires constant calculation involving dozens of
parameters such as ship mass, thrust force and vector,
gravitational forces around, and many others we might not
know yet. These values and units should be a part of the eDSL
itself. Potentially, the scripts can be loaded from the text files,
so there should be a way to describe new unit types and value
types and allow the existing scripts to work with them without
changing the core of the Andromeda software.
LINK Andromeda showcase project for Functional Design and
Architecture
https://github.com/graninas/Functional-Design-and-Archi
tecture/tree/master/Manning-Publications/BookSamples/
CH08/Section8p1/src
Unfortunately, this isn’t a simple task if we want to
simultaneously satisfy three requirements: extensibility, type
safety, and simplicity. It’s again a “pick two out of three” game:
you can choose what two properties to support and what
property to sacrifice. Arguably, the golden middle of having
everything at once either doesn’t exist or is extremely hard to
achieve, see figure D.1:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 301
Figure D.1 Extensibility versus type safety versus simplicity
In Andromeda, the value model is not extensible and isn’t quite
safe. This might be a huge problem because we want to make
spaceship scripts as robust and bug-free as possible. It’s
Haskell, yes, but having a seemingly safe language doesn’t
mean the code will be safe and correct automatically. On the
other hand, Andromeda software was made for a book to
demonstrate functional declarative design, not to make a real
SCADA platform. Its dumbness is, therefore, excusable:
data Value = BoolValue Bool
| IntValue Int
| FloatValue Float
| StringValue String
Not very impressive, huh? I use this type for properties:
type PropertyName = String
data Property
= ValueProperty PropertyName Value #A
| PhysicalUnitProperty PropertyName PhysicalUnit #B
#A Property with a regular value
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 302
#B Property with a physical unit value
A property, for example, a serial number, can suddenly change
its type along the transformations. Here, it changes from
integer to string invisibly to the caller:
serialNumber :: Int -> Property
serialNumber n = ValueProperty "serial number"
(StringValue (show n)) #A
#A It’s a string value now
The client code perceives serial numbers as integers and may
occasionally fail due to wrong assumptions. So, this naive,
oversimplified model is closed for extensibility and prone to
type mismatch problems. Making it safer with enough smart
constructors and accessors is possible, but making it extensible
isn’t.
In the next section, I’ll show you the model from the Zeplrog
project. It is type-safe and extensible, and, as it follows, isn’t
simple, given that it is also type-level.
D.2 Extensible value model of Zeplrog
The value model in the Zeplrog project is part of its imperative
scripting eDSL. Scripting empowers the property object model
and adds possibilities similar to methods in OOP. It’s also
type-level because it’s made specifically for this book. I
presented it in Chapter 7, so let’s be brief here.
D.2.1 Properties and scripts
In the Zeplrog model, we describe a game and its objects with
properties. Every property is a structure similar to classes in
OOP. Properties can have fields, properties can be abstract and
inherited, and they can reference each other in various ways.
Also, properties can have scripts.
Listing D.1 Sample properties: abstract and inherited
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 303
type SomeAbstractProp =
AbstractProp (Group ESomeAbstractProp) '[] '[] #A
type SomeProp = DerivedProp ESomeProp SomeAbstractProp #B
'[ PropKeyVal EHPVal (OwnVal (HPVal 10)) #C
]
'[ PropScript ETest TestScript #D
]
#A Abstract property
#B Specific property
#C Integer field for hit points
#D Some script
Here is a sample script that declares an integer variable for hit
points (HPVar) and performs a writing operation:
Listing D.2 Sample script with a variable
type HPVar = IntVar "hp var" 0
type TestScript = 'Script @'TypeLevel "test script"
'[ DeclareVar HPVar #A
, WriteData (ToVar HPVar) #B
(FromConst (IntConst 30))
]
#A Declare the variable
#B Write the constant value into the variable
Variables may be sources and targets of the writing operation.
They are local to the script but can interact with the fields of
properties in the whole ontology of properties. In the sample
above, the variable will get 30 before the script ends, and
nothing else will happen. The script is meaningless because it
doesn’t affect the property ontology in any way. We could, for
example, update the EHPVal field from the variable:
WriteData (ToField 'Proxy (RelPath '[ EHPVal ])) (FromVar HPVar)
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 304
But it’s again a redundant operation because we could just
update the field from the constant directly:
WriteData (ToField 'Proxy (RelPath '[ EHPVal ]))
(FromConst (IntConst 30))
Operations with variables and constants are type-safe; type
mismatch won’t compile:
type HPVar = IntVar "hp var" 0
type NameVar = StringVar "name var" "John Doe"
type TestScript = 'Script @'TypeLevel "test script"
'[ WriteData (ToVar HPVar) #A
(FromVar NameVar)
]
#A Couldn't match kind ‘"string"’ with ‘"int"’
Let’s investigate the internals of the model.
D.2.2 Variables, values, and tags
Variables in this scripting language should be defined before
use. We specify the name, the type, and the initial value:
type HPVar = IntVar "hp var" 0
If we unwrap the IntVar helper type, we’ll see many
interesting and somewhat cryptic type-level bits:
Listing D.3 The internals of IntVar
type IntVar (name :: Symbol) (i :: Nat)
= GenericVar
@'TypeLevel #A
@IntTag #B
name #C
(IntValue i) #D
#A Level tag
#B Type tag unique to the Int type
#C Name of the variable
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 305
#D Stored integer value
This GenericVar type is a special ADT that declaratively
describes any variable. It has two generic arguments and two
field arguments:
data GenericVarDef (lvl :: Level) (tag :: CustomTag) where
GenericVar
:: StringType lvl #A
-> GenericValDef lvl tag #B
-> GenericVarDef lvl tag
#A Variable name
#B Variable value
Let’s keep GenericValDef aside for a while and focus on
CustomTag.
CustomTag represents a custom type system within Haskell’s
type system. It helps to achieve type safety in this extensible
model. Every type eligible for the value model should have its
tag associated with it. The system uses tags to tell one user
type from another without knowing the types themselves. In
principle, type representations from Haskell’s reflection
(TypeRep from Data.Typeable) might work as type tags. I
decided to avoid this dependency and created CustomTag,
which is a unique type-level string that should accompany every
type:
type IntTag = 'RegularTag "int"
type StringTag = 'RegularTag "string"
So IntVar will hold IntTag internally, which is how the system
ensures that it’s not StringVar. The two variables have
different tags.
There is more to this mechanism: compound types are also
possible. When comparing compound types, we need to
compare their parts. For example, a pair (Int, String)
differs from (String, Int). We have a choice here: get a
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 306
regular tag with a type-level string describing the components
of the pair as a single string or make a compound tag with
subtags corresponding to each component:
type IntStringPairTag1 = 'RegularTag "(int, string)"
type IntStringPairTag2 = 'CompoundTag "(int, string)"
IntTag StringTag
Both will work, but having granular info about the contained
types seems beneficial. To support this, the tag type should be
recursive:
data CustomTag where
RegularTag :: Symbol -> CustomTag
CompoundTag :: Symbol -> CustomTag -> CustomTag -> CustomTag
Notice that it is a type-level type only because it uses Symbol.
From Chapter 4, we know that there are static and volatile
domain notions. This one is static, so we attach it to a volatile
domain notion (generic value) as a type parameter. You can see
it in GenericVarDef and GenericValDef, two more types of
this model. Figure D.2 makes it easier to understand the
relation between them:
Figure D.2 Generic variables and values
GenericVal is the main type that provides extensibility of
values. In listing D.3, it appears as IntValue, another helper.
Internally, IntValue is this:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 307
Listing D.4 The internals of IntValue
type IntValue (i :: Nat)
= GenericValue
@'TypeLevel #A
@IntTag #B
i #C
'DPlaceholder #D
#A Level tag
#B Type tag
#C Value to store
#D Internal mechanism for obtaining a dynamic value
GenericValue has some surprises. It’s not an easy beast. It
belongs to the GenericValDef kind; see listing D.4:
Listing D.4 The type for storing value-level and type-level values
data GenericValDef (lvl :: Level) (tag :: CustomTag) where
GenericValue
:: TagToType lvl tag #A
-> DValue #B
-> GenericValDef lvl tag
#A Type family to adjust the field type using level and tag
#B Field that stores a dynamic value
Before learning its idea, let’s articulate the purpose once again.
It should carry a type-level value of any type not known
upfront. It should be extensible so the developer can specify
values and variables for their arbitrary types. It’s possible, for
example, to have values of a user-defined type Person. See
the following example:
Listing D.5 Using custom type Person as a value
data Person (lvl :: Level) where
PersonImpl
:: StringType lvl -- ^ First name
-> StringType lvl -- ^ Last name
-> Person lvl
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 308
type PersonTag = 'RegularTag "Person" #A
type instance TagToType lvl PersonTag = Person lvl #B
type PersonValue (person :: Person 'TypeLevel) #C
= GenericValue @TypeLevel @PersonTag
person
'DPlaceholder
#A Type tag for Person
#B Type family instance that ties the tag to the type
#C Value type for Person
The GenericValue type will handle this and will provide type
safety through the type tags. It does extensibility not with the
type-level interfaces we discovered in Chapter 8, but with a
new type-level feature we’ll discuss now.
D.2.3 Extensibility with open type families
The extensibility of GenericValDef is based on Haskell’s
type-level feature called open type families. We’ve never met it
before; we’ve only dealt with closed type families so far. In
Chapter 4, type families helped to traverse type-level data
models at compile time and to control their integrity. The
Granular Type Selector design pattern that we introduced in
Chapter 6 is also based on type families. Both occurrences were
closed type families. Once provided, they cannot be extended.
For instance, if it’s a selector for string types depending on the
level, only two options are available:
type family StringType (lvl :: Level) where
StringType 'TypeLevel = Symbol #A
StringType 'ValueLevel = String #B
#A Option 1: pick Symbol if lvl is TypeLevel
#B Option 2: pick String if lvl is ValueLevel
This type is predefined, and the only way to extend it is to
update it directly. A small change, however, may open the type
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 309
family for extensibility. The great news is that, arguably,
nothing bad will happen if we just switch to open type family
syntax:
type family StringType (lvl :: Level) :: *
The last part (:: *) that comes instead of where indicates that
this StringType type family, once fed by a Level type, should
return a regular type of the star kind. Our previous instances
are good, but we need to use another syntax that makes them
independent from each other:
type instance StringType 'TypeLevel = Symbol
type instance StringType 'ValueLevel = String
They may be separated from the type family anywhere in the
project. Nothing else is needed; the rest of the code will
continue working as it did. We could, in principle, make more
additional instances for this type family, if not the
completeness: no type tags other than 'TypeLevel and
'ValueLevel exist. There is no room for new instances.
In contrast, the TagToTypeopen type family from
GenericValDef accepts an unlimited CustomTag
corresponding to many various types.
data GenericValDef (lvl :: Level) (tag :: CustomTag) where
GenericValue
:: TagToType lvl tag #A
-> DValue
-> GenericValDef lvl tag
# Open type family
The Level tag parameter is also there, so this type family has
two arguments:
type family TagToType (lvl :: Level) (tag :: CustomTag) :: *
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 310
The type family ties Level and CustomTag to a user-defined
type so that the result type is always the same if given the two.
The result should certainly be present in the sight of the
compiler as a type family instance:
type instance TagToType lvl IntTag = IntegerType lvl
type instance TagToType lvl StringTag = StringType lvl
type instance TagToType lvl PersonTag = Person lvl
As you can see, we don’t have to specify exact lvl types for
this type-level pattern-matching. Instead, we pass them further
as type parameters: IntegerType lvl, Person lvl. But we
can do that if we need:
type instance TagToType 'TypeLevel (TPHTag vt) = ...
type instance TagToType 'ValueLevel (TPHTag vt) = ...
Now, the value model is extensible. Forgive me for leaving
many details undiscussed, particularly what DValue does in
GenericValDef and why. There are also some tricky moments
with static materialization and dynamic instantiation. Type-level
programming with these concepts is complex, so making it
work is challenging but doable with the knowledge we obtained
so far. I invite you to consult with the code base and
experiment for more insights.
LINK Zeplrog showcase project
https://github.com/graninas/Zeplrog
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 311
Appendix E
Event-based architecture of
the Minefield game
Minefield is a turn-based rogue-like game. The player's main
task is to disarm all mines while avoiding explosions. Don’t
expect much from the game; it’s only a showcase. However, it
has an interesting architecture worth learning and applying to
similar projects. We’re going to discuss these things:
▪ type-level domain model;
▪ event-driven architecture;
▪ actor model;
▪ MVar request-response design pattern.
The code is available here in the book’s repo:
../First-Edition/BookSamples/demo-apps/minefield
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 312
Figure E.1 Game screenshot
E.1 Minefield application architecture
The game is turn-based. After the player runs a command, all
objects on the field should act in unison, one after another, step
by step. There can be mines, empty cells, or other game
objects. Some mines are passive and await external events to
trigger (Landmine). Others may have a countdown timer
(TimerBomb) ticking each turn until it hits zero. Bombs are
customizable. For example, a timer bomb may last for 6, 7, or 8
moves. A landmine may have more or less power or explosion.
Some will only harm the nearest cells, and some may trigger
other bombs, thus sparking a chain reaction.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 313
Figure E.2 Chain reaction screenshots
These and other requirements lead me to develop a custom
event-based actor model. Let me tell you about it.
E.1.1 Event-based actor model
Each object on the field is an actor able to shoot events and
receive events from other actors. The actor owns a private
incoming event queue to which it listens. Actors cannot access
foreign private queues or even know other actors exist.
Instead, they push events to the system bus.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 314
Figure E.3 Event queues and actors
The system bus is passive; it’s just an event queue shared
between all the citizens of this kingdom. Actors can only push
events to the queue, but the governor of the kingdom—the
orchestrator—manages the queue and distributes the events.
The orchestrator is also responsible for step-by-step actor
activation. An actor awaits the step signal from the
orchestrator, and only then does it process a portion of the
events available this time. When the actor finishes, it blocks
itself until the next step signal. Figure E.4 demonstrates a game
loop with events distribution and actor activation:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 315
Figure E.4 Orchestrator and actor event flow
The game loop starts by pushing TurnEvent into the system
bus, targeting all the subscribers. On distribution, these and
possibly other events go from the system bus to the local event
queues. After this, the orchestrator wakes the actors
individually using a special step channel. This MVar
request-response channel is solely needed to trigger and
unblock the actors. Most of the time, the actors just wait on it
until they get a ping from the orchestrator. According to the
MVar request-response pattern, the orchestrator will also be
blocked to wait for a response signal from the actor (we’ll
discuss this pattern in the next sections). Figure E.5 details the
flow:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 316
Figure E.5 MVar request-response scheme for stepping actors
The overall scheme is even more sophisticated than figures E.4
and E.5 present because each turn is also split into ten ticks,
and the actors should process ticks similarly as it’s for turns on
the figure. Ticks are not mandatory for the game; I use them to
animate the objects. So, the actors get ten tick events and one
turn event. On a turn event, a timer bomb with 6 turns will
decrease the counter to 5 and change its icon accordingly. On
tick events, the actor changes its explosions icons in a
sequence: [*, |, /, -, \].
Events target a position on the field and, sometimes, the object
type, but not a specific actor, because actors should be isolated.
E.1.2 Minefield type-level domain model
Defining a game starts with specifying the type-level shape of
the field, available game objects, and the player’s actions. Here
is a 7x7 field type (just a list of type-level strings) that
corresponds to the game screen from figure E.1:
Listing E.1 Minefield definition
type Minefield =
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 317
'[ "7 6 A @"
, " B 7 B 8"
, " C 7 B"
, " 8 A 6 "
, "B A 7 8 8"
, "7 C 7 6"
, " A C 7 C"
]
The GameDef has a list of the object templates with icons.
Icons connect a template and its field instances. For example,
Landmine "B" 2 is a template that means there will be “B”
landmines (with power 2) on the field.
Listing E.2 Game definition
type MyGame = GameDef
Minefield #A
(Player "@") #B
(EmptyCell " ") #C
'[ Landmine "A" 1 #D
, Landmine "B" 2
, Landmine "C" 3
, TimerBomb "6" 6
, TimerBomb "7" 7
, TimerBomb "8" 8
]
'[ PutFlag #E
]
#A Field definition
#B Player definition
#C Empty cell definition
#D Object templates
#E Actions
Currently, there is no correctness verification. Invalid row
lengths are possible; they will be materialized successfully,
resulting in a non-rectangle field that is not guaranteed to work
properly.
type InvalidMinefield1 =
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 318
'[ "7 @"
, " B 7 B 8 "
]
The absence of a player sign on the field will make the game
unplayable.
type NoPlayerField = '[ "6" ]
Unknown symbols (those that don’t have a corresponding
object template) will lead to a runtime exception:
type InvalidMinefield2 = '[ "X@" ]
> minefield: Icon not found: 'X'
Duplicate object definitions and duplicate actions will not be
rejected. Empty fields and empty object icons may cause
strange errors.
All these flaws can be verified at compile time using the
correctness techniques presented in chapter 4. I decided it was
not worth it. The domain is not demanding; it’s just a demo
application, nothing critical. We can tolerate its imperfectness
and keep the model simple (remember the no perfectionism
principle?) if we can call it simple, considering it’s extensible in
two ways.
E.1.3 Domain-level noun-verb extensibility
There are two ways to extend the domain:
▪ Noun extensibility: providing an object template (such as
Landmine) that implements IObjectTemplate and has
several specific type classes instantiated.
▪ Verb extensibility: providing an action (such as PutFlag)
that implements IAction and also has some specific type
classes instantiated.
These extension points are independent, although when adding
an object, all known actions should be instantiated for it, and
vice versa. The EmptyCell and Player objects are system
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 319
implementations of IObjectTemplate and should be explicitly
mentioned in GameDef:
data GameDef
(minefield :: [Symbol])
(player :: IObjectTemplate)
(emptyCell :: IObjectTemplate)
(supportedObjects :: [IObjectTemplate])
(supportedActions :: [IAction])
The type-level interfaces are presented in listing E.3:
Listing E.3 Interfaces
type CommandDef = Symbol
data IObjectTemplate where #A
ObjectTemplateWrapper :: a -> IObjectTemplate
type family MkObjectTemplate a :: IObjectTemplate where #B
MkObjectTemplate a = ObjectTemplateWrapper a
data IAction where #C
ActionWrapper
:: a
-> CommandDef #D
-> IsDirected #E
-> IAction
type family MkAction a cmd dir :: IAction where #F
MkAction a cmd dir = ActionWrapper a cmd dir
#A Interface for game objects (nouns)
#B Type-level existential smart constructor
#C Interface for player’s actions (verbs)
#D String command
#E Is the command directed to the nearest cell
#F Type-level existential smart constructor
A typical game object that implements IObjectTemplate may
look like an empty parametrized ADT containing some specific
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 320
fields. All landmines have explosion power (how big is the
affected neighborhood), so we keep a field for this number:
data LandmineDef
(icon :: Symbol)
(objectType :: Symbol)
(power :: Nat) #A
type Landmine i p = MkObjectTemplate (LandmineDef i "lm" p)
#A Field for power
Actions are what the player can do with the game objects.
Typically, the actions don’t keep any additional information, only
the string command and direction indicator, and these data are
stored in the ActionWrapper. Otherwise, the actions are just
empty ADTs:
data PutFlagDef
type PutFlag = MkAction PutFlagDef "flag" 'True
Putting a flag on a bomb disables it. The action is directed
because the player should specify the nearest cell to flag.
According to the definition above, the user can type such
commands (among others):
> flag R #A
> flag DL #B
#A R means “right”
#B DL means “down-left”
The game will parse the input and find a corresponding
command. If successful, it will then look up a cell next to the
player in the specified direction, and if it finds a game object
there, it will trigger a flag effect over that object. This is a
conceptual scheme of the interaction between a game object
and an action, but implementation is much more sophisticated.
We’re going to touch it a little.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 321
E.2 Minefield application implementation
While the domain model is simple, the implementation poses
various challenges. In particular, we need to convert these
type-level templates into working actors, which we do with
static materialization. Another trickery comes with the
extensibility of the game objects. There is a code that traverses
the field’s description, uses its icons to look up the
corresponding object template in the objects list, instantiates
an actor, and subscribes it to specific events. One more
initialization subsystem constructs a command-line interface
from the extensible actions listed in the definition. Finally, the
game objects (nouns) and actions (verbs) are related. Some
code makes a Cartesian product of them to implement the
reactions of the objects to specific actions.
During this initialization process, we get the following moving
parts:
▪ Game object actors (green threads);
▪ Orchestrator (manages the actors, the queues, and the
game loop in the main thread);
▪ Field watcher (an actor responsible for field rendering; also
a green thread);
▪ Field (a dictionary of cells);
The conceptual scheme of initialization is presented in figure
E.6:
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 322
Figure E.6 Instantiation of a game definition
Let’s now decompose it and see how it’s done.
E.2.1 The MVar request-response pattern
MVars enable thread-safe writing and reading. They do not
prevent race conditions and occasionally block a thread
indefinitely if misused. An MVar can be empty; in this case, the
first thread that accesses it should put something for others to
unblock. An MVar can be full; the first thread that accesses it
takes full ownership and can do its stuff. (MVars have other
implications; consider external resources for more info.)
All actors in the game are green threads. Their step-by-step
activity is regulated by an MVar mechanism called the MVar
request-response pattern. This special mechanism signals when
to step the logic and prevents the threads from spinning
between the steps. Step channels also enable the sequential
triggering of actors.
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 323
The channel consists of two MVar variables, one for request and
another one for response:
data StepChannel = StepChannel
{ cInVar :: MVar ()
, cOutVar :: MVar ()
}
These MVars carry a boring unit value; the fact that such an
MVar is not empty means “currently signaling.” The orchestrator
isn’t interested in any information from the actor; any such
data would make the two coupled and dependent on the value.
Instead, the orchestrator just pokes the actor by putting a unit
request and blocking itself on the response until the actor fills
the unit response. The following figure details this interaction:
Figure E.7 MVar request-response pattern
The actors certainly need to communicate, but they should do
this abstractly without being too aware of each other. Events
can achieve this; let’s see how.
E.2.2 Events and queues
An event is a message one actor may broadcast to all other
interested actors. The sender usually doesn’t know who the
recipients are (although sometimes it can if needed). Events
can be transferred via event queues. I use MVars for them,
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 324
although any thread-safe approach may work (for example,
STM):
type EventQueueVar = MVar [SystemEvent]
There are various events in the game. Most of them are
“system” ones relating to infrastructure work. Some invite the
player actor to interact with the user, ask the field actors to
populate their current image, tick events, etc.
Listing E.4 System events
data SystemEvent
= PlayerInputRequestEvent #A
| PlayerInputEvent PlayerPos String #B
| PopulateIconRequestEvent #C
| FieldIconEvent Pos Icon #D
| TickEvent Int #E
| TurnEvent Int
| ActorRequestEvent #F
ObjectType ActorPos ActorRequest
#A Requesting the player actor to ask the user for a command
#B The user entered some command
#C Requesting the actors to populate their current icons
#D Current icon at the field position
#E Notifying the actors about a tick and a turn
#F Actor-to-actor events
Currently, the game object mechanic is extensible, thus
enabling actors with an unknown implementation; however, the
set of events is not extensible. What if those unknown actors
want to exchange specific data? As the simplest possible
solution, I added a loophole in the form of a generic request
event from the following ActorRequest data type:
data ActorRequest
= AddOverhaulIcon OverhaulIcon
| SetEnabled Bool
| SetDisarmed Bool
| GenericRequest String #A
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 325
#A Serialized generic request
ActorRequest is actor-to-actor events. The GenericRequest
constructor contains a string for any possible serializable data.
This should be enough for the actors to implement any
interaction logic. For example, there can be a door and a key
with the events “lock” and “unlock” from the key to the door
and “key mismatch” the other way. Both actors will depend on
the corresponding data types coming with them, which is
acceptable because the actors and the events belong to the
same domain.
Before getting to the local queues, all events flock to the
system bus, which is an unordered event queue:
data SystemBus = SystemBus
{ sbEventsVar :: EventQueueVar #A
, sbSubscriptionsVar :: MVar [Subscription] #B
}
#A Common event queue
#B Subscribers
Publishing means appending an event to others in this queue:
publishEvent :: SystemBus -> SystemEvent -> IO ()
publishEvent (SystemBus evsVar _) ev = do
evs <- takeMVar evsVar
putMVar evsVar $ ev : evs
-- example:
publishEvent sysBus PopulateIconRequestEvent
When a specific phase of the game starts (for instance, the next
tick), the orchestrator extracts the events from the bus and
distributes them across the subscribers. For details, you can
familiarize yourself with the distributeEvents function from
the source code; it’s a simple algorithm that pushes the events
to only subscriptions satisfying the filtering condition. A
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 326
subscription, therefore, is a condition plus the actor’s local
event queue:
data Subscription = Subscription
{ sCondition :: SystemEvent -> Bool
, sRecipientQueueVar :: EventQueueVar
}
A typical subscription condition is just a boolean tester of an
event type:
isGameFlowEvent :: SystemEvent -> Bool
isGameFlowEvent ev = isTickEvent ev || isTurnEvent ev
sub :: SystemEvent -> Bool
sub ev = isPopulateIconRequestEvent ev
|| isGameFlowEvent ev
|| isActorRequestEvent ev
The actors should process events to which they have
subscribed. In response, they can publish more events that will
be available to the recipients on the next game tick. For
example, when the player actor gets the input invitation event,
it asks the user to type a command and pushes the result back:
processPlayerEvent
:: SystemBus
-> PlayerObject
-> SystemEvent
-> GameIO ()
processPlayerEvent sysBus obj PlayerInputRequestEvent = do
pos <- readIORef $ poPos obj
line <- withInputInvitation "Type your command:"
publishEvent sysBus $ PlayerInputEvent pos line
All the actors have a right to subscribe to it, but only the
orchestrator is truly interested because it parses the command
and converts it into an effect on the game mechanics.
Due to the publishing-subscribing mechanism, the event-based
actor model generally has low coupling and better extensibility
but a higher accidental complexity. Event pumping mechanisms
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 327
and actors all require a specific infrastructure. In principle, I
could use Kafka or other external message brokers for event
management. With these solutions, I could gain additional
perks such as fault tolerance, event persistence, automatic
statistics indicators, load balancing, etc. I decided that
succumbing to the Not Invented Here (NIH) syndrome and
crafting a custom actor model would give me a self-contained
application – crucial for educational projects. However, custom
actor models may not make much sense for enterprise
software.
E.2.3 Actor model implementation
Type-level object definitions (object templates) should be
materialized into runtime objects. Object templates may carry
specific info, like power for LandmineDef, so the runtime
objects should have a value-level representation. Besides,
runtime objects may keep other information for their game
mechanic functionality. This is the object template for
landmines:
data LandmineDef
(icon :: Symbol)
(objectType :: Symbol)
(power :: Nat)
The operational data type for the runtime object will have more
rows: power, state of a landmine, and position on the field; see
the following listing:
Listing E.5 Lanmdine runtime object
data ObjectInfo = ObjectInfo #A
{ oiObjectType :: ObjectType
, oiEnabled :: Bool
, oiIcons :: (Icon, [OverhaulIcon])
}
data LandmineState
= LandmineActive
| LandmineDead
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 328
| LandmineDisarmed
| LandmineExplosion Int -- ticks left
data LandmineObject = LandmineObject
{ loObjectInfoRef :: IORef ObjectInfo
, loPos :: Pos
, loPower :: Int
, loStateRef :: IORef LandmineState #B
}
#A General information about the game object
#B State of the game object
The ObjectInfo type is common to all runtime objects, but
LandmineObject is private and will only be visible to its actor.
Essentially, actors encapsulate runtime data and hide
implementation details from other actors. This is why actors are
very close to Alan Kay’s object-oriented programming. Alan Kay,
a Turing Award laureate and a creator of the programming
language Smalltalk, has also coined the term “object-oriented
programming” for the model of objects sending messages to
each other. Later, the term “object-oriented” was hijacked by
languages such as C++ and Java and started representing a
slightly different concept. You might have also heard Erlang’s
philosophy aligns closely with Kay’s original vision, where the
primary interaction mechanism is messaging (events), and
objects are sealed and have some information encapsulated.
Producing an actor starts by collecting general info about the
object from its template. There are three verbs to implement:
data GetIcon = GetIcon
data GetObjectInfo = GetObjectInfo
data GetObjectType = GetObjectType
I use a single EvalIO type class for materialization.
class EvalIO payload verb noun ret
| verb noun -> ret where
evalIO :: payload -> verb -> Proxy noun -> IO ret
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 329
The instances are straightforward. For example, this one,
GetIcon, returns the icon of the template:
instance ( KnownSymbol i ) =>
EvalIO () GetIcon (LandmineDef i ot p) Icon where
evalIO () _ _ = pure $ head $ symbolVal $ Proxy @i
A more involved code will trigger the actor’s instantiation. There
is a MakeActor verb for this:
data MakeActor = MakeActor
Instantiation of a landmine actor is presented in listing E.6. This
instance comes independently from the core logic as a part of
the Landmine “plugin”.
Listing E.6 Actor instantiation
instance
( t ~ LandmineDef i ot p
, EvalIO () GetObjectInfo t ObjectInfo
, KnownNat p
) =>
EvalIO (SystemBus, Pos) MakeActor
(LandmineDef i ot p)
Actor where
evalIO (sysBus, pos) _ _ = do
stepChan <- createStepChannel #A
queueVar <- createQueueVar
let p = fromIntegral $ natVal $ Proxy @p #B
oInfo <- evalIO () GetObjectInfo $ Proxy @t
obj <- create LandmineObject oInfo pos p LandmineActive
tId <- forkIO $ actorWorker stepChan queueVar #C
$ processLandmineEvent sysBus obj
let sub ev = #D
isPopulateIconRequestEvent ev
|| isGameFlowEvent ev
|| isActorRequestEvent ev
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 330
subscribeRecipient sysBus $ Subscription sub queueVar
pure $ Actor tId stepChan queueVar
#A Create the queue and the step var
#B Get the info
#C Create the actor
#D Subscribe the actor to certain events
Notice the processLandmineEvent function; it is where the game
logic lives. I won’t show it all, but here it is:
processLandmineEvent
:: SystemBus
-> LandmineObject
-> SystemEvent
-> GameIO ()
processLandmineEvent sysBus obj (TurnEvent _) = pure ()
processLandmineEvent sysBus obj (TickEvent tick) = do
let stateRef = loStateRef obj
let oInfoRef = loObjectInfoRef obj
state <- readIORef stateRef
case state of
LandmineExplosion ticksLeft -> ...
In short, this is all that constitutes the actor. However, there is
one more thing about how the actor should react to certain
player commands. We need to implement another type class
instance that, if the command is valid, returns an effect to
perform. Effects here typically consist of just a bunch of events
to send. For example, the disarmBomb effect:
disarmBombEffect :: ActorAction
disarmBombEffect sysBus oType pos = do
publishEvent sysBus
$ ActorRequestEvent oType pos
$ SetDisarmed True
Alexander Granin / Pragmatic Type-Level Design v.0.9.1 331
The instance for the PutFlag and Landmine types is not
difficult:
instance
( t ~ LandmineDef i ot p
, EvalIO () GetObjectType t ObjectType
) =>
EvalIO () MakeActorAction
(ObjectVerb (LandmineDef i ot p) PutFlagDef)
(ObjectType, ActorAction) where
evalIO () _ _ = do
oType <- evalIO () GetObjectType $ Proxy @t
pure (oType, disarmBombEffect)
Of course, these instances don’t hand in the thin air. There is
machinery in the game engine that dispatches the
materialization and instantiation for all the IObjectTemplate
and IAction types. If you are interested in knowing more and
want to see all that infrastructure with its type class instances,
type-level recursion, and type-level pattern matching, you are
welcome to the project’s repo.