articles

View on GitHub

Functional Typescript: Opaque Types

Objective oriented programming paradigm provides a wide set of encapsulation mechanisms. It has public/private/protected class-level modificators for fields and inheritance which create a high level of encapsulation. But what is the notion of encapsulation? Why do we want to have it in general? There are a couple of reason why do we might to have this trick in our toolbelt:

The third option can actually be easily broken via inheritance (this is one of the reasons why inheritance considered as an antipattern).

But this is OOP, in functional programming there is no classes and therefore no visibility modificators. Data types are usually defined top-level and completely separated from functions. So should we give up encapsulation in FP?

Actually no. There is diligent pattern to achieve the same level of encapsulation in functional programming, it’s called Opaque Types.

What is Opaque Types?

There is no special keyword or some special modificator to create this sort of types. Opaque Type is just an algebraic type that exposes its definition but hides type constructors. That’s it, nothing too fancy. Still don’t get it :-)? Then it’s time for examples.

Implementation

Opaque Types are widely used in many strongly typed functional languages, like Haskell or Elm, but for some reason I’ve never seen even a mention them in TypeScript context. This is unfortunate, because they can be easily implemented and very usefull there as well. Let’s try to do it by example.

Implementing ADT

In my previous article I explained Algebraic Data Types in TypeScript in all details, please read it first if you did not do it already, we will do an extensive usage of this concept. So, first we need to create and algebraic data type.

In this example we are going to implement Question type for some quiz. Question can be in two states, answered and not. If question is not answered yet, then we want to have the full information, like question text, list of available answers and the number of a correct answer as well. If question is already answered then we don’t need most of its information anymore, we need only the selected answer number and is this correct or not.

const UNANSWERED = 'UNANSWERED';
const ANSWERED = 'ANSWERED';

interface Unanswered {
    kind: typeof UNANSWERED;
    
    text: string;
    answers: Array<string>;
    correctAnswer: number;
}

interface Answered {
    kind: typeof ANSWERED;
    
    selectedAnswer: number;
    isCorrect: boolean;
}

type Question = Unanswered | Answered;

Restrict visibility rules

Ok, we have the type now, but this type if very open now. Anyone can create a question in any of its states. Like, for example what does it mean to have an answered question without having unanswered before? What if one creates an unanswered question where correctAnswer number is bigger than the length of answers array? All sort of inconsistency may happen and right now we can not prohibit this.

A couple of simple questions arises here:

If the question were class, most likely it would have a private constructor and some public construction function, that allows only defined set of operations, but we don’t have public/private modificators here, so how to achieve the same level of encapsulation? We can do it by using export keyword

// type constants are not exported, so they can not be used from the
// external code
const UNANSWERED = 'UNANSWERED';
const ANSWERED = 'ANSWERED';

// as well as type interfaces, nobody except our own module can use them
// in other languages these are usually called type constructors
interface Unanswered {
    kind: typeof UNANSWERED;
    
    text: string;
    answers: Array<string>;
    correctAnswer: number;
}

interface Answered {
    kind: typeof ANSWERED;
    
    selectedAnswer: number;
    isCorrect: boolean;
}

// but type itself should be exposed to the outside world
export type Question = Unanswered | Answered;

Constructor functions

Ok, now we have only type, what can we do with it from the external code? We can define a variable of this type:

import { Question } as Question from 'question';

let question: Question;

We can define function that accepts or returns Question:

function iAcceptQuestion(question: Question): void {...}
function iReturnQuestion(): Question {..}

And actually nothing more. But we miss a way to create an instance of our type. The classical way to do that is to create a constructor function inside our module. In this constructor we can do all sort of verifications and confirmations and create only consistent state:

export function create(text: string, answers: string[], correctAnswer: number): Question {
    if (answers.length < 2)
        throw new Error('It doesn\'t make sense to have a question with less than two answers');
    if (correctAnswer < 0 || correctAnswer >= answers.length)
        throw new Error('The number of correct answer should be in the range of answers');
        
    return { kind: UNANSWERED, text, answers, correctAnswer };
}

All these exceptions look a bit non-FP, and they are also really hard to compose. Instead, we can use a Result type, but this is the material for the full article, so will wait till later.

After we create our constructor function it would be nice to have some workflow functions as well. For example, in this case we need a way to answer a question.

export function answer(selectedAnswer: number, question: Question): Question {
    switch (question.kind) {
        case UNANSWERED: { // since now compiler knows that question is of Unanswered type
            if (selectedAnswer < 0 || selectedAnswer >= question.answers.length)
                throw new Error('The number of selected answer should be in the range of answers');
            return {
                kind: ANSWERED,
                isCorrect: selectedAnswer === question.correctAnswer,
                selectedAnswer,
            };
        }
        case ANSWERED:
            // question is already answered, answer the same question twice
            // is not allowed in our system, so just return question
            return question;
    }
}

Our implementation and the structure of the type is completely hidden now, therefore we don’t have a way to get any information from it, we can not read any field. But we still need to know something, for example is our question answered or not, is it answered correctly. We have only one way to implement this and only one place to put the implementation: the question module

export function isAnswered(question: Question): boolean {
    return question.kind === ANSWERED;
}

export function isAnsweredCorrectrly(question: Question): boolean {
    switch (question.kind) {
        case UNANSWERED:
            // Something that is not answered is not answered correctly either
            return false;
        case ANSWERED:
            return question.isCorrect;
    }
}

Usage

Using Opaque Types is quite simple. Just import the module and use functions:

import * as Q from './question';

// Creation of an instance
const question: Q.Question = Q.create(
    "What is your favorite prgramming language?",
    ["JavaScript", "TypeScript", "Elm"],
    1,
);

Q.isAnswered(question); // => false
Q.isAnsweredCorrectrly(question); // => false

// Answer created question
const answeredQuestion: Q.Question = Q.answer(1, question);

Q.isAnswered(answeredQuestion); // => true
Q.isAnswered(answeredQuestion); // => true

Please do not forget that all data types should be immutable. We’ll have one more article about immutability, but for now just try to never mutate anything.

Why is it helpful

The Opaque Types give us the same level of encapsulation, safety and consistency as classes from OOP, but the cost is quite lower, we don’t have implicit this state, which might be source of all sorts of disaster. Also opaque types do not own your data, your code own data (as you can see in the example above), module for opaque types just provide a useful interface to work with your data, which might be very important in some cases.

Downsides

Every poverfull tool has its own downsides that come with it. Opaque Types is not an exception. You should be careful when choosing to make a type opaque. First of all, implementation will be hidden, which is very good in some cases and very bad in others. If you find yourself writing getters and setter for most fields in your type (as we did with isAnswered and isAnsweredCorrectly), then most likely this type should NOT be opaque.

Also, by making type opaque, you close the set of functions that can operate on internal structure of the type. This is much more strict constraint than OOP class, because you can extend class, but you can never extend opaque type. Functions that are written in the module are the only functions that can operate on internal structure of your type.

Wraping Up

In this article I explained how can we encapsulate type inside the module by using Opaque Type, we’ve looked at the example usage and detailed implementation. This will be very helpful when we get back to implement our quiz app later. In the next article I show what is immutability, why is this important, how to use it correctly, what are persistent data transformations and persistent data types. This will be the last step before combining everything together to create a functional app.