Welcome to Part 3 of the DCI tutorial! If you just arrived, check out Part 1 and Part 2 first.
You have probably used Roles many times before
Roles are the central locus of a DCI Context, and chances are that you unknowingly have used them before. Let's take a look at a more realistic example: An AJAX form event handler that will display validation messages, but they should all be hidden when submitting the form and displayed when the response arrives.
The initial code could be something like this:
function submitForm(e: SubmitEvent) {
e.preventDefault();
if (!(e.target instanceof HTMLFormElement))
throw new Error('No form found.');
const messages =
e.target.querySelectorAll('[data-form-message]')
as NodeListOf<HTMLElement>;
// ...to be continued
And from there, you want to show and hide the messages, so you create functions for that:
const hideMessages = () => {
messages.forEach((m) => (m.style.display = 'none'));
};
const showMessage = (name: string) => {
messages.forEach((m) => {
if (m.dataset.formMessage == name) m.style.display = 'unset';
});
};
But these functions have too much in common not to be factorized, so you do that with a setMessage
function:
const setMessage = (display: string, name = '') => {
for (const msg of messages) {
if (name && msg.dataset.formMessage != name) continue;
msg.style.display = display;
}
};
const showMessage = (name: string) => setMessage('unset', name);
const hideMessages = () => setMessage('none');
And finally, a bit of simplified code for the actual form submission:
const form: HTMLFormElement = e.target as HTMLFormElement;
hideMessages();
fetch(form.action, { method: 'POST' })
.then((response) => response.json())
.then((data) => data.errors?.forEach(showMessage));
}
Problem solved, for now. Let's say that the next feature request is to automatically scroll to the topmost error message. So you'll add another function or two, copy/paste a helper function from Stack Overflow, and everything's fine again for a while. And the next is to scratch the itch of data-form-message
being hardcoded, so options are introduced. And so on...
Adding functionality in this manner makes our mini-system quickly start to fragment. Variables will be spread throughout, with a multitude of functions that are used from anywhere within it. Let's look at the initial ones:
setMessage
,showMessage
,hideMessages
Both namewise and by their usage, they're related to the messages
variable that we created earlier, which is an identifier for an array of HTML elements. So maybe they should be grouped like methods on an object, pointing us to a class as a possible solution?
Classes to the rescue?
class FormMessages {
readonly messages: NodeListOf<HTMLElement>;
constructor(form : HTMLFormElement) {
this.messages = form.querySelectorAll('[data-form-message]');
}
set(display: string, name = '') {
for (const msg of this.messages) {
if (name && msg.dataset.formMessage != name) continue;
msg.style.display = display;
}
}
show(name: string) {
this.set('unset', name);
}
hide() {
this.set('none');
}
}
One problem with classes, with their static set of functionality and relationships, is that they are falling into what the authors of DCI call "Restricted OO". By introducing a separate class, we'll be confronted by FormMessages
instead of a literal type, which means we have to look up and grasp the intricacies of that class before being able to fully understand what our form submit function does. Check the link for more details, it's quite a read.
Objects to the rescue, then?
We're transpiling to Javascript, which can create objects in other ways than classes, so can we circumvent the class issue? (no, not the real-world issue!)
Unfortunately not; the problem with objects, instantiated by classes or not, is that they are encapsulating three things: state, behavior and identity, but in our case, we only need one.
State is already contained in a
NodeListOf<HTMLElement>
That list has identity as well, which can be tested with an equality operator, and could lead to very subtle bugs if identity is violated by wrappers
All we need is the behavior we defined earlier:
setMessage
,showMessage
,hideMessages
.
We are clearly missing a language feature that handles our case, and as you probably have figured out, that would be a DCI Role.
Roles to the rescue!
If Roles would be a built-in feature of Typescript, our Messages
Role could perhaps look like this:
// ...inside a Context
role Messages {
// Role contract
Iterable<HTMLElement>;
// RoleMethods
show(name: string) {
set('unset', name);
}
hide() {
set('none');
}
private set(display: string, name: string) {
for (const msg of self) {
if (name && msg.dataset.formMessage != name) continue;
msg.style.display = display;
}
}
}
...but lacking this, we have to make compromises like the lamented underscore convention. Our faux-Role in Typescript looks like this:
const Messages : Iterable<HTMLElement> =
e.target.querySelectorAll(messageSelector);
// RoleMethods
function Messages_show(name: string) {
Messages_set('unset', name);
}
function Messages_hide() {
Messages_set('none');
}
function Messages_set(display: string, name = '') {
for (const msg of Messages) {
if (name && msg.dataset.formMessage != name) continue;
msg.style.display = display;
}
}
This is the reason the ESLint DCI library was created - to ensure the closest possible emulation of Roles.
Rewriting the form validation
This part is getting long enough, so let's wrap it up by rewriting the form submit function as a DCI Context:
// @DCI-context
function SubmitForm(e: SubmitEvent) {
e.preventDefault();
if (!(e.target instanceof HTMLFormElement))
throw new Error('No form found.');
// Role (Data)
const Messages: Iterable<HTMLElement> =
e.target.querySelectorAll('[data-form-message']);
// RoleMethods (System behavior)
function Messages_show(name: string) {
Messages__set('unset', name);
}
function Messages_hide() {
Messages__set('none');
}
function Messages__set(display: string, name = '') {
for (const msg of Messages) {
if (name && msg.dataset.formMessage != name) continue;
msg.style.display = display;
}
}
{
// System operation (Interaction)
const form: HTMLFormElement = e.target as HTMLFormElement;
Messages_hide();
fetch(form.action, { method: 'POST' })
.then((response) => response.json())
.then((data) => data.errors.forEach(Messages_show));
}
}
The code is available on Stackblitz as usual.
Connecting back to the start - "You have probably used Roles before" - we've been doing it all the time, creating an identifier and functions with related behavior to it, but lacking the language feature it's very hard to see it - and honestly very hard to live without when you've grasped the concept of Roles.
It's very rare to see Role-like concepts in programming languages today, but the closest I've seen is Haxe with its Abstract types, though that cannot be a real DCI Role because of a reason that will be explained later. (On the other hand, Haxe has other features that enable true DCI.)
Private RoleMethods
One last thing: A modification made to the code is Messages__set
. The double underscore declares a private RoleMethod, which further encapsulates behavior only related to a Role. In this case, Messages.set
was made to prevent code duplication, and we don't want other Roles, or the Context itself, to use it. A Role should only expose what's purposeful in its interaction with others in a Context, which raises an important question: If there is only one Role, can there be interaction?
This question will be answered in the next part. Keep reading in Part 4!
Bonus points
The only real DCI language out there, trygve, is taking an interesting approach to iterables by defining Role vectors, which map behavior to one item at a time. Read more about it, and about DCI in depth, in the comprehensive trygve user manual, available here.