Crafting our own Option type in typescript
# Who is that article for
In that article we will build our own Option type from the ground up.
It requires a bit of typescript knowledge. It will be a step by step process.
# What's our goal?
We will start from a simple program that
- Asks the user its name
- Crafts a message depending on whether the user provided a name or not
- Logs a greeting message
What we will be looking for is a way to express that program using types first.
# The program
function userInput(): string | null {
// some implementation
}
/** we will store the greeting message in that variable */
let greetingMessage: string
console.log('What is your name?')
const userName: String | null = userInput() // ask for the user name
if (userName !== null) {
greetingMessage = `Hello user, I will call you ${userName} from now on`
} else {
greetingMessage = 'Hello stranger! It seems I didn\'t quite catch your name'
}
console.log(greetingMessage)
# Finding a type saying a value can be empty
As of now the userInput function can either return a string or null but what if we wanted to
return only one stable type instead of two?
From here, we need to find this new type.
It should convey the idea that userInput can either return some value or none at all.
Starting with this in mind we can define two new types: Some and None
// None tells that it doesn't hold any value
type None = { isSome: false }
// Some tells it has some value of type A and hold it in a `value` field.
// A could be any type we could think of as in Some<String> or Some<number>.
type Some<A> = { isSome: true, value: A }
So our userInput can now return Some<String> | None, for our convenience we will introduce a
type saying exactly this:
// An Option can either have Some value of type A or None at all
type Option<A> = None | Some<A>
which leads us to have a new signature for our userInput function conveying within one type that
it is allowed to return nothing.
function userInput(): Option<String> {
// some implementation
}
# Rewrite of our program using the new signature
Leveraging the new signature we can make the if statement closer to english
let greetingMessage: string
console.log('What is your name?')
const userName: Option<String> = userInput()
if (userName.isSome) {
greetingMessage = `Hello user, I will call you ${userName.value} from now on`
} else {
greetingMessage = 'Hello stranger! It seems I didn\'t quite catch your name'
}
console.log(greetingMessage)
userName.value can only be accessed if we first check userName.isSome making the program already
safer.
# Giving Option some friends
Now that we have a cool type, we will give that type some functions to help it make it nicer.
# The map function
We are looking into isSome then accessing the value, we could provide a function that says
something like:
Given an option which might contain A
apply this function going from A to B
So we have an option which might contain B
We will call that function a map since it maps our Option<A> to Option<B> using our function.
Map signature will look like that:
function map<A, B>(transformation: (value: A) => B): (option: Option<A>) => Option<B> {
}
so we can now rewrite our program this way:
function greetAKnownUser(userName: String) {
return `Hello user, I will call you ${userName} from now on`
}
let greetingMessage: string
console.log('What is your name?')
const userName: Option<String> = userInput()
const knownUserMessage = map(greetAKnownUser)(userName)
if (knownUserMessage.isSome) {
greetingMessage = knownUserMessage.value
} else {
greetingMessage = 'Hello stranger! It seems I didn\'t quite catch your name'
}
console.log(greetingMessage)
It starts to look closer and closer to some english statements, the one bit staying is this if
/else.
# The getOrElse function
What could we imagine from here to totally get rid of that if else?
We are now looking for a function saying something like:
Given an option which might contain A
And a default value of type A
Pick the value contained in the option, on None, pick the default value
Return a value of type A in any case
In other words, we would like to get the value or else get the default so the name seem clear, it is
the getOrElse function:
/**
* If option has some value, then it will return it, otherwise it will return the default passed value
*/
function getOrElse<A>(orElseValue: A): (option: Option<A>) => A {
}
Let's rewrite our program using that
function greetAKnownUser(userName: String) {
return `Hello user, I will call you ${userName} from now on`
}
const greetStranger =
'Hello stranger! It seems I didn\'t quite catch your name'
console.log('What is your name?')
const userName: Option<String> = userInput()
const knownUserMessage = map(greetAKnownUser)(userName)
const greetingMessage = getOrElse(greetStranger)(knownUserMessage)
console.log(greetingMessage)
Stopping here for a little, there are multiple things to say about what we have.
First, there is no longer a let awaiting the program to go in one of our if/else
statement to be filled.
From now on there is only constant using the value of the previous constant to decide its own
value.
Second, there is no more if/else and in broader manner, there is no more imperative statements.
We didn't provide any implementation yet for map and getOrElse and looking at the code we don't
need it to know what the program does!
It is still not that nice to read, we could have a final utils to help us on the reading part
# The pipe function
The pipe function is a dumb function. It takes a list of functions and calls the next one with the result of the previous one.
function pipe<A>(...ListOfFunctions): A {
// some implementation
}
Which gives us this final result:
const greetAKnownUser = (userName: String) =>
`Hello user, I will call you ${userName} from now on`
const greetStranger =
'Hello stranger! It seems I didn\'t quite catch your name'
console.log('What is your name?')
const greetingMessage = pipe(
userInput(),
map(greetAKnownUser),
getOrElse(greetStranger)
)
console.log(greetingMessage)
# Conclusion
We now have the same program written twice:
- the first one doesn't express the result of the program but tells what steps the computer should exactly take to obtain the correct message.
- The second version declares the result of the program without telling which steps to take to achieve that result.
The declarative version tells what result we expect without telling how it should achieve such result
What did we gain introducing the Option type in our code? The first thing we got is safety!
Indeed, by design our code won't allow to end up in a null situation anymore therefore anyone who
works using our Option type will have to treat null edge cases.
We also gained in reusability and composability. We can split that program in multiple sub-functions that could be easily reused since it operates against the same type encasing the same assumptions.
On the other hand, we lost a bit in readability, after a while you get use to write and read your code in a functional flavor. It requires some habits and training making the code less accessible for the majority of the developers' community.
We also lost contact with the actual implementation, which imply that we trust whoever
implemented getOrElse, map or pipe to have the correct implementation and the most stack safe
possible.
So shall we invest more time in it? Knowing how to write such code seem to me important to understand that there is many ways to write the same code with different constraint and goals. In context of safety (our code can't produce uncontrolled edge-cases), bringing such functional practices fits well.
# If you have more time
# About testing
We can now write things like:
function getUserGreetingFromOption(option: Option<String>): String {
const knownUserMessage = map(greetAKnownUser)(userName)
const greetingMessage = getOrElse(greetStranger)(knownUserMessage)
return greetingMessage
}
const greetingMessage = getUserGreetingFromOption(userInput())
console.log(greetingMessage)
Why is that ability nice? because it will ease testing our system behavior by providing inputs and outputs. We no longer requires to mock userInput if we wish to test the return message we can directly do something like:
describe('getUserGreetingFromOption', () => {
it('should greet stranger on none', () =>
expect(getUserGreetingFromOption({isSome: false})).toEqual(greetStranger))
it('should greet Amy', () =>
expect(getUserGreetingFromOption({isSome: true, value: 'Amy'}))
.toEqual(greetAKnownUser('Amy')))
})
# Full implementation
////// Option
/// Definitions
// None tells that it doesn't hold any value
type None = { isSome: false }
// Some tells it has some value of type A and hold it in a `value` field
type Some<A> = { isSome: true, value: A }
// An Option can either have Some value of type A or None at all
type Option<A> = None | Some<A>
/// constructors
const some: <A>(value: A) => Option<A> = value => ({
isSome: true,
value
})
const none: () => None = () => ({
isSome: false
})
/// destructors
const getOrElse: <A>(orElseValue: A) => (option: Option<A>) => A =
defaultValue => option => option.isSome ? option.value : defaultValue
/// operations
const map: <A, B>(transformation: (value: A) => B) => (option: Option<A>) => Option<B> =
transformation => option => option.isSome ? some(transformation(option.value)) : none()
////// Functions
/// Pipes
const pipe3: <A, B, C>(fn1: A, fn2: (a: A) => B, fn3: (b: B) => C) => C =
(fn1, fn2, fn3) => fn3(fn2(fn1))
// ******************************************
const userInput = (): Option<String> =>
some('Amy') // you can try with none()
const greetAKnownUser = (userName: String) =>
`Hello! I will call you ${userName} from now on`
const greetStranger =
'Hello! It seems I didn\'t quite catch your name'
console.log('What is your name?')
const greetingMessage = pipe3(
userInput(),
map(greetAKnownUser),
getOrElse(greetStranger)
)
console.log(greetingMessage)
This article has been reviewed by Erwan Guyomarc'h and Alice Rouquette .