Declarative programming is an interesting approach. You basically define “This is what I want” and then let some other type figure out how it should work.
Specifically, with declarative programming, we focus more on expressing logic and structure. We then worry less about control flow, such as figuring out the order of method calls, and keeping track of local state.
As mobile devs, we may associate declarative programming with a fancy syntax to build UI, such as follows in Jetpack Compose where we configure a Modifier
:
modifier = Modifier
.padding(24.dp)
.fillMaxWidth()
Or a similar notation, in SwiftUI, where we modify a Text element:
Text("I am some text")
.padding([.bottom, .trailing], 20)
.bold()
.font(.title)
But there are two ideas to unpack here:
We’ll explore both these ideas while designing our own custom declarative type in this article.
Let’s see how we can harness about this power by designing some API’s for a type.
Let’s imagine that we are asked to design a field validator that we’ll creatively call Validator
. A type that takes user input (text) and gives back whether it’s valid, used for forms and fields.
For forms we could use good old regular expressions. Initially, a validator might not be that different from regular expressions.
However, we could treat a validator as a higher abstraction that can better focus on forms and fields, and can ideally give specific errors, such as "Letters are not allowed in a phone number" or "Your password must contain at least 20 different egyptian hieroglyphs".
We’ll design the API from the call-site, so that we can worry about the implementation afterwards. Because how we use it is most important, then how it’s implemented becomes an “implementation detail”. This makes it easier to design an API.
At the end of this exercise, we also make sure this type will support chaining via declarative programming.
We can imagine that a Validator
accepts certain rules. Each rule accepts an an anonymous function, that takes an input (a string) and tells us whether it’s valid, by returning either true
or false
.
Following convention, let’s call this anonymous function a predicate.
Before we build a Validator
type with fancy predicates, let’s start simple. The simplest version we can design is a Validator
type that always says a string is valid.
Designing from the call-site, we see how we create a new Validator
, and we can set its rules
property, which can hold an array of rules. We’ll start with a Rule
that always returns true, so that any string is always valid.
Finally, we pass a string to the Validator
to validate, using the check()
method.
We'll use Swift for the examples. But the programming language isn't always too important. You can apply these concepts to other languages, too.
var validator = Validator()
// We add rules
validator.rules = [
// There is only one rule. It receives a string.
// But it always returns true (string is valid)
Rule(predicate: { string in true })
]
// Then we can pass a string to the validator
// to check whether it's valid.
// No matter what string we pass
// the validator says it's always valid (true).
check("Filled string") // true
// Empty strings are still valid
validator.check("") // true
Since we can match anything with strings, we already have a ton of flexibility. For example, we can have one rule that disallows white space, and another rule that the character limit needs to be less than 4.
var validator = Validator()
validator.rules = [
// No empty strings allowed.
Rule(predicate: { string in !string.isEmpty }),
// Max four chars
Rule(predicate: { string in string.count < 4 })
]
validator.check("") // false
validator.check("abc") // true
validator.check("abcdef") // false
We built a little structure; We introduced a Validator
, which contains some Rule
instances. Even though these can be small types, we managed to have some expressivity.
We could already say this is declarative, even without chaining types. Without dealing too much with control flow, we merely state “These are the rules, now make it work”. Which is not that different from “This is what I want in my view, now render it for me”.
For completion’s sake, let’s see what this implementation would look like in Swift.
A Rule
can be tiny; A small struct that contains the predicate. This predicate is a function that provides a string (the input to check) and returns a boolean.
struct Rule {
let predicate: (String) -> Bool
}
Implementing the Validator
itself can also start small.
It contains an array of Rule
types. When checking, it passes the string to each rule. If one Rule
returns false, the check fails. Otherwise, the check passes and check
returns true.
struct Validator {
var rules = [Rule]()
func check(_ string: String) -> Bool {
for rule in rules {
if !rule.predicate(string) {
return false
}
}
return true
}
}
This is already enough to make our implementation work. Everything compiles, and with just a few lines of code, we have a very flexible validator already!
You’re probably here to see some of that fancy declarative chaining. Let’s see how we could make that happen.
First, we’ll again design it from the call-site.
We introduce a little factory method called makeNameValidator()
to prove that we don’t need to return anything anymore explicitly.
To configure a Validator
, we need to chain some sort of method. Let’s call this method rule()
, which again accepts a predicate.
Notice that we can now keep calling the rule
method to pass validation rules.
func makeNameValidator() -> Validator {
Validator()
.rule { string in !string.isEmpty }
.rule { string in string.count > 4 }
.rule { string in string.count < 20 }
}
That’s looking quite declarative or DSL-like already!
DSL stands for Domain Specific Language. A higher level of expression or abstraction to describe a problem in a programming language.
Notice that, thanks to chaining, we don’t need to return anything in makeNameValidator()
. This is because every rule
modifier returns a brand new Validator
, making this the implicit return type.
The secret sauce to chaining is to return a type after every method call. Most commonly, we return the same type as the owner of the method.
By calling rule
on Validator
, we ensure to return a new Validator
so that we can keep chaining. For our use-case, this new Validator
will need to support a new rule, and all previous rules before that.
We’ll define the rule
method that accepts a predicate and returns a Validator
. To make the compiler happy, let’s start by adding a placeholder body that just returns the current validator.
struct Validator {
// ... snip
// We now return a Validator
func rule(predicate: (String) -> Bool) -> Validator {
// Placeholder: We just return the current validator.
// We ignore the passed predicate for now.
return self
}
This already compiles and runs.
Because rule(predicate:)
returns a Validator
, we always end up with a Validator
. Because of that, we can repeatedly call rule
on it.
// We can infinitely call rule
Validator()
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
.rule { string in true }
It’s a silly example, and similar to calling bold()
ten times on a Text
, hoping it turns superduper-bold. But the point is, we unlocked chaining! This looks closer to declarative programming.
Next up, let’s make the implementation work.
Our implementation only contains a placeholder called self
. This doesn’t work yet, because we need to combine all the rules whenever we call the rule
method on Validator.
The implementation is different for every type you make. In the case of Validator
, that means that whenever we call rule
, we need to create a new validator and add the rules from the previous validator. Then, we add the freshly passed predicate
to it. This way, the new validator has all old and new rules.
Finally, we return the new Validator
containing all rules.
struct Validator {
func rule(predicate: @escaping (String) -> Bool) -> Validator {
// Create a new validator
var combined = Validator()
// Copy over the current rules
combined.rules = self.rules
// We add the new rule
combined.rules.append(Rule(predicate: predicate))
// Return the new validator
return combined
}
}
The escaping keyword tells Swift that a closure might outlive the method's lifecycle.
That’s all the code we need to create a DSL-like declarative style Validator
!
This is just a starting point to build a full-fledged Validator library.
We can get fancy and even combine validators.
Because our implementation is so tiny, we can easily add more small building blocks like these.
For example, let’s say we have a default validator that ensures fields aren’t empty. On top of that, let’s say we have a validator that ensures a user doesn’t exceed the maximum number of characters. We might reuse these validators across the entire application, so we choose to offer these as factory methods.
func makeNotEmptyValidator() -> Validator {
Validator()
.rule { string in !string.isEmpty }
}
func makeMaxCharValidator(max: Int) -> Validator {
Validator()
.rule { string in string.count < max }
}
With a little work, we can combine these two validators.
First, let’s support the boolean and &&
operator. In Swift, we achieve this with a static function that we’ll lovably call &&
.
struct Validator {
// .. snip
// We introduce the && operator.
// Then we can use it such as:
// let combinedValidator = validator1 && validator2
static func && (left: Validator, right: Validator) -> Validator {
// We make a new validator
var combined = Validator()
// We combine all rules from two validators
combined.rules = left.rules + right.rules
return combined
}
}
That’s all it takes. Now we can easily combine validators.
Below, we combine the two validators into one, using the custom &&
operator!
let combined = makeNotEmptyValidator() && makeMaxCharValidator(max: 4)
combined.check("") //false
combined.check("abc") // true
combined.check("abcdef") // false
It took little effort, but it’s powerful. With a little imagination, we can come up with other boolean operators, such as ||
or operators or XOR operators.
In the next example below, we combine the or ||
operator with the and &&
operator.
Here, the field isn’t allowed to be empty. But, as long as the field is a user ID or email, we can consider it valid.
let notEmpty = makeNotEmptyValidator()
let isUserID = makeUserIDValidator()
let isEmail = makeEmailValidator()
// We combine all three validators using || and &&
let validator = notEmpty && (isUserID || isEmail)
validator.check("") //false
validator.check("@tjeerdintveen") // true
validator.check("[email protected]") // true
Using custom operators allows us to express validators in a more DSL-like way.
Next, let’s take this idea one step further.
To continue with our example, in Swift we can use something called Result builders, allowing us to make things more implicit, removing some of the noise in our code. This brings us closer to a DSL with a declarative feel.
Then our code could look something like this:
Validator {
Rule { string in !string.isEmpty }
Rule { string in string.contains("@") }
Rule { string in string.count < 20 }
}
Now it’s even more DSL like.
We can keep going; Such as supporting regular expressions. Or combining rules with And
and Or
operations. Perhaps we could design this with a RuleGroup
that takes various And
or Or
statements.
Validator {
// We could support a custom RegExp rule.
// Such as a phonenumber check.
RegExp { "/^(\+\d{1,2}\s?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/" }
// We design a group of rules that work together
// with boolean operations, such as Or and And
RuleGroup {
Or { string in string.count > 10 }
Or { string in string.count < 20 )
} And {
Rule { string in !string.isEmpty }
}
}
The idea would be to first design the declarative DSL you like, and then try to get as close as possible. With some ideas from this article, I think we can get really close to this design.
This is just a Swift example. But even without Swift’s result builders, this library can go in many directions. Such as making validators collect various errors, or supporting types other than strings, such as dates and numbers.
Maybe we can even offer transformations. Such as a validator that trims the white space, so that other combined validators always get a proper string.
With a little creativity and a combination of small operators, we can grow a mature system that can be expressed in a declarative, DSL-like manner.
We’ve been describing the rules of a validator without describing how it should validate. So that we focus less on control flow.
I hope this article gave you some ideas and inspiration to come up with your own design.
We’ve seen how declaratively defining an API doesn’t always mean we have to chain methods. But, it brings us to a higher-level language of expression, closer to a DSL. These examples show we can chain method calls to define elements declaratively .
That means that chaining is one shape to handle declarative programming. But there are many more.
We’ve also seen that declarative programming isn’t bound to UI. This Validator
library could very well work in a model-only context.
If you want to see more declarative examples like these, then check out Chapter 16: Reusing views across flows of the Mobile System Design book.
The Mobile System Design book
If you want to boost your career and become an even better mobile engineer, then check out the Mobile System Design book.
I am the author of this book, but you may also know me from the highly-rated Swift in Depth book.
Even if you're a senior or staff-level engineer, I am confident you'll get a lot out of it.
It covers topics in depth such as:
... and much more!
Get the Mobile System Design bookWritten by
Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.
He is the author of the Mobile System Design and Swift in Depth books.