Welcome to the final installment of our Deno series, where we build a script that pairs up people for coffee.
In the last post, we added the ability to post messages into a Slack channel instead of copying from a console window.
The current major problem is that we have to remember to run the script. We could always set up a cron job or scheduled task, however, what happens when we change machines? What if our computer stops working? What if someone else changes the script, how will we remember to get the latest and run it?
Welcome to the third installment of our Deno series, where we build a script that pairs up people for coffee.
In the last post, we're dynamically pulling members of the Justice League from GitHub instead of a hardcoded list.
Like any good project, this approach works, but now the major problem is that we have to run the script, copy the output, and post it into our chat tool so everyone knows the schedule.
It'd be better if we could update our script to post this message instead. In this example, we're going to use Slack and their incoming webhook, but you could easily tweak this approach to work with other tools like Microsoft Teams or Discord.
For this step, we'll follow the instructions in the docs, ensuring that we're hooking it up to the right channel.
After following the steps, you should see something like the following:
We can test that things are working correctly by running the curl command provided. If the message Hello World appears in the channel, congrats, you've got the incoming webhook created!
With this secret added, we can write a new function, sendMessage, that'll make the POST call to Slack. Since this is a new integration, we'll add a new file, slack.ts to put it in.
// Using axiod for the web connectionimportaxiodfrom"https://deno.land/x/axiod@0.26.2/mod.ts";// function to send a message to the webhookasyncfunctionsendMessage(message:string):Promise<void>{// Get the webhookUrl from our environmentconstwebhookUrl=Deno.env.get("SLACK_WEBHOOK")!;try{// Send the POST requestawaitaxiod.post(webhookUrl,message,{headers:{"Content-Type":"application/json",},});}catch(error){// Error handlingif(error.response){returnPromise.reject(`Failed to post message: ${error.response.status}, ${error.response.statusText}`);}returnPromise.reject("Failed for non status reason "+JSON.stringify(error));}}export{sendMessage};
With sendMessage done, let's update index.ts to use this new functionality.
import{load}from"https://deno.land/std@0.195.0/dotenv/mod.ts";import{GetOrganizationMemberResponse,getMembersOfOrganization,}from"./github.ts";import{sendMessage}from"./slack.ts";import{Pair,createPairsFrom,shuffle}from"./utility.ts";awaitload({export:true});// Replace this with your actual organization nameconstorganizationName="JusticeLeague";constnames=awaitgetMembersOfOrganization(organizationName);constpairs=createPairsFrom(shuffle(names));constmessage=createMessage(pairs);// Slack expects the payload to be an object of text, so we're doing that here for nowawaitsendMessage(JSON.stringify({text:message}));functioncreateMessage(pairs:Pair<GetOrganizationMemberResponse>[]):string{constmapper=(p:Pair<GetOrganizationMemberResponse>):string=>`${p.first.login} meets with ${p.second.login}${p.third?` and ${p.third.login}`:""}`;returnpairs.map(mapper).join("\n");}
And if we were to run the above, we can see the following message get sent to Slack.
Nice! We could leave it here, but we could make the message prettier (having an unordered list and italicizing names), so let's work on that next.
So far, we could leave the messaging as is, however; it's a bit muddled. To help it pop, let's make the following changes.
Italicize the names
Start each pair with a bullet point
Since Slack supports basic Markdown in the messages, we can use the _ for italicizing and - for the bullet points. So let's modify the createMessage function to add this formatting.
functioncreateMessage(pairs:Pair<GetOrganizationMemberResponse>[]):string{// Let's wrap each name with the '_' characterconstformatName=(s:string)=>`_${s}_`;constmapper=(p:Pair<GetOrganizationMemberResponse>):string=>// and start each pair with '-'`- ${formatName(p.first.login)} meets with ${formatName(p.second.login)}${p.third?` and ${formatName(p.third.login)}`:""}`;returnpairs.map(mapper).join("\n");}
By making this small change, we now see the following message:
The messaging is better, but we're still missing some clarity. For example, what date is this for? Or what's the purpose of the message? Looking through these docs, it seems like we could add different text blocks (like titles). So let's see what this could look like.
One design approach is to encapsulate the complex logic for dealing with Slack and only expose a "common-sense" API for consumers. In this regard, I think using a Facade pattern would make sense.
We want to expose the ability to set a title and to set a message through one or more lines of text. Here's what that code would look like
// This class allows a user to set a title and lines and then use the// 'build' method to create the payload to interact with SlackclassMessageFacade{// setting some default valuesprivateheader:string;privatelines:string[];constructor(){this.header="";this.lines=[];}// I like making these types of classes fluent// so that it returns itself.publicsetTitle(title:string):MessageFacade{this.header=title;returnthis;}publicaddLineToMessage(line:string|string[]):MessageFacade{if(Array.isArray(line)){this.lines.push(...line);}else{this.lines.push(line);}returnthis;}// Here's where we take the content that the user provided// and convert it to the JSON shape that Slack expectspublicbuild():string{// create the header block if set, otherwise nullconstheaderBlock=this.header?{type:"header",text:{type:"plain_text",text:this.header,emoji:true},}:null;// convert each line to it's own sectionconstlineBlocks=this.lines.map((line)=>({type:"section",text:{type:"mrkdwn",text:line},}));returnJSON.stringify({// take all blocks that have a value and set it hereblocks:[headerBlock,...lineBlocks].filter(Boolean),});}}
With the facade in place, let's look at implementing this in index.ts
// ... code to get the pairs and formatted lines// using the facade with the fluent syntaxconstmessage=newMessageFacade().setTitle(`☕ Random Coffee ☕`).addLineToMessage(formattedPairs).build();awaitsendMessage(message);
When we run the script now, we get the following message:
In this post, we changed our script from posting its Random Coffee message to the console window to instead posting it into a Slack channel using an Incoming Webhook. By making this change, we were able to remove a manual step (e.g., us copying the message into the channel), and we were able to use some cool emojis and better formatting.
In the final post, we'll take this one step further by automating the script using scheduled jobs via GitHub Actions.
As always, you can find a full working version of this bot on my GitHub.
constnames=["Batman","Superman","Green Lantern","Wonder Woman","Static Shock",// one of my favorite DC heroes!"The Flash","Aquaman","Martian Manhunter",];constpairs=createPairsFrom(shuffle(names));constmessage=createMessage(pairs);console.log(message);functionshuffle<T>(items:T[]):T[]{constresult=[...items];for(leti=result.length-1;i>0;i--){constj=Math.floor(Math.random()*i);[result[i],result[j]]=[result[j],result[i]];}returnresult;}typePair<T>={first:T;second:T;third?:T};functioncreatePairsFrom<T>(items:T[]):Pair<T>[]{if(items.length<2){return[];}constresults=[];for(leti=0;i<=items.length-2;i+=2){constpair:Pair={first:items[i],second:items[i+1]};results.push(pair);}if(items.length%2===1){results[results.length-1].third=items[items.length-1];}returnresults;}functioncreateMessage(pairs:Pair<string>[]):string{constmapper=(p:Pair<string>)=>`${p.first} meets with ${p.second}${p.third?` and ${p.third}`:""}`;returnpairs.map(mapper).join("\n");}
Even though this approach works, the major problem is that every time there's a member change in the Justice League (which seems to happen more often than not), we have to go back and update the list manually.
It'd be better if we could get this list dynamically instead. Given that the League are great developers, they have their own GitHub organization. Let's work on integrating with GitHub's API to get the list of names.
To get the list of names from GitHub, we'll need to do the following.
First, we need to figure out which GitHub endpoint will give us the members of the League. This, in turn, will also tell us what permissions we need for our API scope.
Now that we have a secret, we need to update our script to read from an .env file.
Once we have the secret being read, we can create a function to retrieve the members of the League.
Miscellaneous refactoring of the main script to handle a function returning complex types instead of strings.
Before we start, we should reactor our current file. It works, but we have a mix of utility functions (shuffle and createPairsFrom) combined with presentation functions (createMessage). Let's go ahead and move shuffle and createPairsFrom to their own module.
import{Pair,createPairsFrom,shuffle}from"./module.ts";constnames=["Batman","Superman","Green Lantern","Wonder Woman","Static Shock",// one of my favorite DC heroes!"The Flash","Aquaman","Martian Manhunter",];constpairs=createPairsFrom(shuffle(names));constmessage=createMessage(pairs);console.log(message);functioncreateMessage(pairs:Pair<string>[]):string{constmapper=(p:Pair<string>)=>`${p.first} meets with ${p.second}${p.third?` and ${p.third}`:""}`;returnpairs.map(mapper).join("\n");}
What's the difference between the two? In GitHub parlance, an Organization is an overarching entity that consists of members which, in turn, can be part of multiple teams.
Using the Justice League as an example, it's an organization that contains Batman, and Batman can be part of the Justice League Founding Team and a member of the Batfamily Team.
Since we want to pair everyone up in the Justice League, we'll use the Get members of an Organization approach.
To interact with the endpoint, we'll need to create an API token for GitHub. Looking over the docs, our token needs to have the read:org scope. We can create this token by following the instructions here about creating a Personal Auth Token (PAT).
Once we have the token, we can invoke the endpoint with cURL or Postman to verify that we can communicate with the endpoint correctly.
After we've verified, we'll need a way to get this API token into our script. Given that this is sensitive information, we absolutely should NOT check this into the source code.
Our problem now is that if we check git status, we'll see this file listed as a change. We don't want to check this in, so let's add a .gitignore file.
import{configasloadEnv}from"https://deno.land/x/dotenv@v3.2.2/mod.ts";// other imports// This loads the .env file and adds them to the environment variable listawaitloadEnv({export:true});// Deno.env.get("name") retrieves the value from an environment variable named "name"console.log(Deno.env.get("GITHUB_BEARER_TOKEN"));// remaining code
When we run the script now with deno run, we get an interesting prompt:
Deno requests read access to ".env".
- Requested by `Deno.readFileSync()` API.
- Run again with --allow-read to bypass this prompt
- Allow?
This is one of the coolest parts about Deno; it has a security system that prevents scripts from doing something that you hadn't intended through its Permissions framework.
The permissions can be tuned (e.g., you're only allowed to read from the .env file), or you can give blanket permissions. In our cases, two resources are being used (the ability to read the .env file and the ability to read the GITHUB_BEARER_TOKEN environment variable).
Let's run the command with the allow-read and allow-env flags.
deno run --allow-run --allow-env ./index.ts
If the bearer token gets printed, we've got the .env file created correctly and can proceed to the next step.
// Brining in the axiod libraryimportaxiodfrom"https://deno.land/x/axiod@0.26.2/mod.ts";asyncfunctiongetMembersOfOrganization(orgName:string):Promise<any[]>{consturl=`https://api.github.com/orgs/${orgName}/members`;// Necessary headers are found on the API docsconstheaders={Accept:"application/vnd.github+json",Authorization:`Bearer ${Deno.env.get("GITHUB_BEARER_TOKEN")}`,"X-GitHub-Api-Version":"2022-11-28",};try{constresp=awaitaxiod.get<any[]>(url,{headers:headers,});returnresp.data;}catch(error){// Response was received, but non-2xx status codeif(error.response){returnPromise.reject(`Failed to get members: ${error.response.status}, ${error.response.statusText}`);}else{// Response wasn't receivedreturnPromise.reject("Failed for non status reason "+JSON.stringify(error));}}}
To prove this is working, we can call this function in the index.ts file and verify that we're getting a response.
import{configasloadEnv}from"https://deno.land/x/dotenv@v3.2.2/mod.ts";import{getMembersOfOrganization}from"./github.ts";import{Pair,createPairsFrom,shuffle}from"./utility.ts";awaitloadEnv({export:true});constmembersOfOrganization=awaitgetMembersOfOrganization('JusticeLeague');console.log(JSON.stringify(membersOfOrganization));// rest of the file
At this point, we're making the call, but we're using a pesky any for the response, which works, but it doesn't help us with what properties we have to work with.
Looking at the response schema, it seems the main field we need is login. So let's go ahead and create a type that includes that field.
typeGetOrganizationMemberResponse={login:string};asyncfunctiongetMembersOfOrganization(orgName:string):Promise<GetOrganizationMemberResponse[]>{//codeconstresp=awaitaxiod.get<GetOrganizationMemberResponse[]>(url,{headers:headers});// rest of the code}
We can rerun our code and verify that everything is still working, but now with better type safety.
// Need to update the input to be Pair<GetOrganizationMemberResponse>functioncreateMessage(pairs:Pair<GetOrganizationMemberResponse>[]):string{// Need to update mapper function to get the login propertyconstmapper=(p:Pair<string>):string=>`${p.first.login} meets with ${p.second.login}${p.third?` and ${p.third.login}`:""}`;returnpairs.map(mapper).join("\n");}
With this last change, we run the script and verify that we're getting the correct output, huzzah!
Congratulations! We now have a script that is dynamically pulling in heroes from the Justice League organization instead of always needing to see if Green Lantern is somewhere else or if another member of Flash's SpeedForce is here for the moment.
A working version of the code can be found on GitHub.
In a previous post, I mention my strategy of building relationships through one-on-ones. One approach in the post was leveraging a Slack plugin, Random Coffee, to automate scheduling these impromptu conversations.
I wanted to leverage the same idea at my current company; however, we don't use Slack, so I can't just use that bot.
Thinking more about it, the system wouldn't be too complicated as it has three moving parts:
Get list of people
Create random pairs
Post message
To make it even easier, I could hardcode the list of people, and instead of posting the message to our message application, I could print it to the screen.
With these two choices made, I would need to build something that can shuffle a list and create pairs.
Even though we're hardcoding the list of names and printing a message to the screen, I know that the future state is to get the list of names dynamically, most likely through an API call. In addition, most messaging systems support using webhooks to create a message, so that would be the future state as well.
With these restrictions in mind, I know I need to use a language that is good at making HTTP calls. I also want this automation to be something that other people outside of me can maintain, so if I can use a language that we typically use, that makes this more approachable.
In my case, TypeScript fit the bill as we heavily use it in my space, the docs are solid, and it's straightforward to make HTTP calls. I'm also a fan of functional programming, which TypeScript supports nicely.
My major hurdle at this point is that I'd like to execute this single file of TypeScript, and the only way I knew how to do that was by spinning up a Node application and using something like ts-node to execute the file.
Talking to a colleague, they recommended I check out Deno as a possible solution. The more I learned about it, the more I thought this would fit perfectly. It supports TypeScript out of the box (no configuration needed), and a file can be ran with deno run, no other tools needed.
This project is simple enough that if Deno wasn't a good fit, I could always go back to Node.
With this figured out, we're going to create a Deno application using TypeScript as our language of choice.
Once Deno has been installed and configured, you can run the following script and verify everything is working correctly. It creates a new directory called deno-coffee, writes a new file and executes it via deno.
constnames=["Batman","Superman","Green Lantern","Wonder Woman","Static Shock",// one of my favorite DC heroes!"The Flash","Aquaman","Martian Manhunter",];constpairs=createPairsFrom(shuffle(names));constmessage=createMessage(pairs);console.log(message);
This code won't compile as we haven't defined what shuffle, createPairsFrom, or createMessage does, but we can tackle these one at a time.
Since we don't want the same people meeting up every time, we need a way to shuffle the list of names. We could import a library to do this, but what's the fun in that?
In this case, we're going to implement the Fisher-Yates Shuffle (sounds like a dance move).
functionshuffle(items:string[]):string[]{// create a copy so we don't mutate the originalconstresult=[...items];for(leti=result.length-1;i>0;i--){// create an integer between 0 and iconstj=Math.floor(Math.random()*i);// short-hand for swapping two elements around[result[i],result[j]]=[result[j],result[i]];}returnresult;}constwords=["apples","bananas","cantaloupes"];console.log(shuffle(words));// [ "bananas", "cantaloupes", "apples" ]
Excellent, we have a way to shuffle. One refactor we can make is to have shuffle be generic as we don't care what array element types are, as long as we have an array.
But what happens if Martian Manhunter is called away and isn't available? That would leave Aquaman without a pair to have coffee with (sad trombone noise).
In the case that we have an odd number of heroes, the last pair should instead be a triple which would look like the following:
Given that we've been using the word Pair to represent this grouping, we have a domain term we can use. This also means that createPairsFrom has the following type signature.
// Using an optional propertytypePair{first:string,second:string,third?:string}// Using Discriminated UnionstypePair={kind:'double',first:string,second:string}|{kind:'triple',first:string,second:string;third:string}
For now, I'm thinking of going with the optional property and if we need to tweak it later, we can.
functioncreatePairsFrom(names:string[]):Pair[]{// if we don't have at least two names, then there are no pairsif(names.length<2){return[];}constresults=[];for(leti=0;i<=names.length-2;i+=2){constpair:Pair={first:names[i],second:names[i+1]};results.push(pair);}if(names.length%2===1){// we have an odd length// Assign the left-over name to the third of the tripleresults[results.length-1].third=names[names.length-1];}returnresults;}// Example executionconsole.log(createPairsFrom(["apples","bananas","cantaloupes","dates"]));// [{first:"apples", second:"bananas"}, {first:"cantaloupes", second:"dates"}]console.log(createPairsFrom(["ants","birds","cats"]));// [{first:"ants", second:"birds", third:"cats"}]
Similarly to shuffle, we can make this function generic as it doesn't matter what the array element types are, as long as we have an array to work with.
functioncreateMessage(pairs:Pair<string>[]):string{constmapper=(p:Pair<string>)=>`${p.first} meets with ${p.second}${p.third?` and ${p.third}`:""}`;pairs.map(mapper);}
From here, we can join the strings together using the \n (newline) character.
functioncreateMessage(pairs:Pair<string>[]):string{constmapper=(p:Pair<string>)=>`${p.first} meets with ${p.second}${p.third?` and ${p.third}`:""}`;returnpairs.map(mapper).join("\n");}
denoruncoffee.ts
"Superman meets with Wonder WomanBatman meets with The FlashMartian Manhunter meets with AquamanStatic Shock meets with Green Lantern"
From here, we have a working proof of concept of our idea. We could run this manually on Mondays and then post this to our messaging channel (though you might want to switch the names out). If you wanted to be super fancy, you could have this scheduled as a cron job or through Windows Task Scheduler.
The main takeaway is that we've built something we didn't have before and can continue to refine and improve. If no one likes the idea, guess what? We only had a little time invested. If it takes off, then that's great; we can spend more time making it better.
In this post, we built the first version of our Random Coffee script using TypeScript and Deno. We focused on getting our tooling working and building out the business rules for shuffling and creating the pairs.
In the next post, we'll look at making this script smarter by having it retrieve a list of names dynamically from GitHub's API!
As always, you can find a full working version of this bot on my GitHub.
When learning about functional programming, you won't go far before you run into the concept of a function. But we're not talking about some syntax or keywords in the language, but from a mathematical sense.
A function is a mapping between two sets such that for every element in the first set, it's mapped to a single element in the second set.
Since a set is a collection of elements, this is similar to a type where values are part of that type. With this understanding, we can modify our definition of a function to be:
A function is a mapping between two types such that for every value in the first type, it's mapped to a single value in the second type.
Before diving into code, let's build up our understanding of functions more. When drawing out the function, we can model it like this.
Generic mapping from A to B
The first type (A) is a circle where each possible value is listed, using ... to denote a pattern. The arrows map from each value of A to a value in B.
With this example, we know we have a function if the mapping satisfies the following rule:
Every element on the left maps to a single element on the right.
This rule seems easy enough to follow, but let's look at a mapping where this rule doesn't hold.
Let's say that we needed to write some code that could take a given month and return the number of days it has. Given this, here's what the mapping would look like.
Mapping from month name to days in month
To check if we have a function, we need to see if there's any element on the left with two or more arrows coming out.
In this case, February is breaking our rule because it could map to 28 or 29, depending on if it's a leap year. Since there isn't a parameter to denote if it's a leap year, our mapping isn't consistent and can't be a function.
One way to fix this would be to change our type on the left from MonthName to MonthName and year. Making this change gives us this new mapping.
First, we have the pure function. These functions depend wholly on their inputs and they do not interact with outside state. For example, pure functions won't interact with databases, file systems, random generation, or time.
Pure functions are great because they're easy to test, composed with other functions, and don't modify state. Another advantage is that pure functions can be replaced with the result of their call (in other words, you could replace a call to square(3) with its result, 9 and the program is the same). This is known as referential transparency and is a great tool for troubleshooting an application.
The main downside to pure functions is that since they don't talk to other systems (including input/output), it's impossible to write a useful program with just pure functions.
Impure functions, on the other hand, focus on interacting with outside state. These functions will call to the database or other systems, get the time, and generate random data as needed.
They allow us to write useful programs because they interact with input/output, however, the trade off is that they can be harder to test, they don't compose, and since they modify state, you may not be able to run them multiple times and get the same result.
One way to identify an impure function is by looking at its type signatures. For example, a function that takes inputs but returns void has to be modifying state or talking to another system, otherwise, why would you call it? Another signature is a function that takes no inputs, but it can return a value (like readLine() from nodejs), where did it get the value from? Just short of returning a constant value, it had to get it from somewhere.
Building an application requires both pure and impure functions, but how do we leverage the best of both worlds?
When I think about software, I think about how data flows through the system. For example, if someone is using a console application, they're inputting their commands to the terminal, which in turn converts them to commands to run, where the output is displayed on the screen.
As such, an application is made of three kinds of functions.
Boundary functions - These are responsible for getting input/output. They should have zero business rules (or the least possible as they are impure functions.
Business functions - These are the business specific rules that need to be ran on the given inputs. As these are typically the most important of an application, they are designed as pure functions
Workflow functions - This is the combination of boundary components and business components to build something useful. Since they're using impure functions, this will also be impure, but I often use this as my composition root for the application.
// Impure function that allows us to get number from userfunctiongetInput():number{// using prompt-sync https://github.com/heapwolf/prompt-syncconstprompt=require("prompt-sync")({sigint:true});constresponse=prompt("What number to calculate FizzBuzz to?");if(!(+response)||(+response)<1){console.log('Invalid response, please enter a positive number greater than 1');returngetInput();}return+response;}// Function that wraps console.log for printingfunctionprintOutput(content:string):void{console.log(content);}
At this point, we have a way of getting a number via getInput and a way to print a string via printOutput. In printOutput, this is a tiny function with no business rules whatsoever. getInput, however, has some business rules about validation, but we'll see later on how to refactor this.
For now, let's leave these two and look into creating our business rule functions.
// Business rules for FizzBuzzfunctioncalculateFizzBuzz(input:number):string{if(input%3==0&&input%5==0){return"FizzBuzz";}if(input%3==0){return"Fizz";}if(input%5==0){return"Buzz";}return`${input}`;}// Helper function to create a range of numbers from [1..end]functioncreateRangeFromOneTo(end:number):number[]{if(number<1){return[];}returnArray.from(Array[number].keys()).map((x)=>x+1);}
With calculateFizzBuzz defined, we could write unit tests to ensure the correctness of the behavior. We could also create a mapping to double-check that we have a function.
Now, let's revisit our getInput function. We've got some business rules that deal with validation (e.g. the input must be a number and greater than 1). Given that this is a light business rule, we could leave it here; however, testing this becomes harder because we don't have a way to ensure that the validation works as expected.
To solve this problem, we could extract the validation logic to its own pure function and update getInput to use the new function.
functionisInputValid(input:string):boolean{if(!(+input)){returnfalse;}return(+input)>1;}functiongetInput():number{// using prompt-sync https://github.com/heapwolf/prompt-syncconstprompt=require("prompt-sync")({sigint:true});constresponse=prompt("What number to calculate FizzBuzz to?");if(!isInputValid(response)){console.log('Invalid response, please enter a positive number greater than 1');returngetInput();}return+response;}
Nice! With this in place, we can go ahead and implement our last function, the FizzBuzz workflow.
functionrunFizzBuzzWorkflow():void{// Data coming inconstmaximumNumber=getInput();// Calculating resultsconstresults=createRangeFromOneTo(maximumNumber).map((x)=>calculateFizzBuzz(x));// Print Resultsresults.forEach((x)=>printOutput(x));}// example invocationrunFizzBuzzWorkflow();
This is a straightforward implementation as we get the maximumNumber to calculate, create an array of numbers from 1 to maximumNumber, map each of those to their FizzBuzz representation, and then print them to the screen.
Let's go one step forward. In our example, we assumed that the input and output was coming from the console, but what if we needed to change to read and write to a file?
We could move the boundary functions to be parameters to runFizzBuzzWorkflow, let's take a look at what that would give us.
functionrunFizzBuzzWorkflow(readInput:()=>number,writeOutput:(string)=>void){// Data coming inconstmaximumNumber=readInput();// Calculating resultsconstresults=createRangeFromOneTo(maximumNumber).map((x)=>calculateFizzBuzz(x));// Print Resultsresults.forEach((x)=>writeOutput(x));}// example invocationsrunFizzBuzzWorkflow(getInput,printOutput);// using console read/writerunFizzBuzzWorkflow(()=>42,printOutput);// using hardcoded input with console log
With this change, we can now swap out how we can input or output by creating new functions with the right type signatures. This makes testing workflow components easy because you can stub in your own mocks (no need for mocking frameworks).
If you understand the power of switching out your boundaries, then you also understand other modern architectures like Ports and Adapters as they follow a similar principle.
In this post, we looked at what a function is, how it relates to types, and how to tell if a mapping is a function. From there, we covered the differences between pure and impure functions and how you need both to build any useful application. Finally, we wrapped up by implementing the FizzBuzz problem using this approach.
When I think about software, I like designing software so that doing the right things are easy and doing the wrong things are impossible (or at least very hard). This approach is typically called falling into the pit of success.
Having a well-defined domain model can prevent many mistakes from happening just because the code literally won't let it happen (either through a compilation error or other mechanisms).
I'm a proponent of functional programming as it allows us to model software in a better way that can reduce the number of errors we make.
Let's at one of my favorite techniques discriminated unions.
[{"id":6430295168,"url":"https://api.github.com/repos/github/roadmap/issues/events/6430295168","event":"locked","commit_id":null,"commit_url":null,"created_at":"2022-04-13T20:49:13Z","lock_reason":null,},{"id":6430296748,"url":"https://api.github.com/repos/github/roadmap/issues/events/6430296748","event":"labeled","commit_id":null,"commit_url":null,"created_at":"2022-04-13T20:49:34Z","label":{"name":"beta","color":"99dd88"},},{"id":6635165802,"url":"https://api.github.com/repos/github/roadmap/issues/events/6635165802","event":"renamed","commit_id":null,"commit_url":null,"created_at":"2022-05-18T19:29:01Z","rename":{"from":"Secret scanning: dry-runs for enterprise-level custom patterns (cloud)","to":"Secret scanning: dry-runs for enterprise-level custom patterns"},}]
Based on the name of the docs, it seems like we'd expect to get back an array of events, let's call this TimelineEvent[].
Let's go ahead and define the TimelineEvent type. One approach is to start copying the fields from the events in the array. By doing this, we would get the following.
This definition will work, as it will cover all the data. However, the problem with this approach is that lock_reason, label, and rename had to be defined as nullable as they can sometimes be specified, but not always (for example, the lock_reason isn't specified for a label event).
Let's say that we wanted to write a function that printed data about TimelineEvent, we would have to write something like the following:
functionprintData(event:TimelineEvent){if(event.event==="labeled"){console.log(event.label!.name);// note the ! here, to tell TypeScript that I know it'll have a value}elseif(event.event=="locked"){console.log(event.lock_reason);}else{console.log(event.rename!.from);// note the ! here, to tell Typescript that I know it'll have a value}}
The main problem is that the we have to remember that the labeled event has a label property, but not the locked property. It might not be a big deal right now, but given that the GitHub API has over 40 event types, the odds of forgetting which properties belong where can be challenging.
The pattern here is that we have a type TimelineEvent that can have different, separate shapes, and we need a type that can represent all the shapes.
typeLockedEvent={id:number;url:string;event:"locked";// note the hardcoded value for eventcommit_id?:string;commit_url?:string;created_at:string;lock_reason?:string;};typeLabeledEvent={id:number;url:string;event:"labeled";// note the hardcoded value for eventcommit_id?:string;commit_url:string;created_at:string;label:{name:string;color:string;};};typeRenamedEvent={id:number;url:string;event:"renamed";// note the hardcoded value for eventcommit_id?:string;commit_url?:string;created_at:string;rename:{from:string;to:string;};};
At this point, we have three types, one for each specific event. A LockedEvent has no knowledge of a label property and a RenamedEvent has no knowledge of a lock_reason property.
Next, we can update our definition of TimelineEvent to use the union operator as so.
functionprintData(event:TimelineEvent){if(event.event=="labeled"){console.log(event.label.name);// note that we no longer need !}elseif(event.event=="locked"){console.log(event.lock_reason);}else{console.log(event.rename.to);// note that we no longer need !}}
Not only do we not have to use the ! operator to ignore type safety, but we also have better autocomplete (note that locked_reason and rename don't appear when working with a labeled event).
At a general level, what we've modeled is a sum type and it's great for when you have a type that can take on a finite number of differing shapes.
Sum types are implemented as either tagged unions or untagged unions. Typescript has untagged unions, however, other languages like Haskell and F#, use tagged unions. Let's see what the same implementation in F# would have looked like.
// specific type definitions omitted since they're// similar to typescript definition// ....typeTimelineEvent=LockedofLockedEvent|LabeledofLabeledEvent|RenamedofRenamedEventletprintDatae=matchewith|Lockedl->printf"%s"l.lock_reason|Labeledl->printf"%s"l.label.name|Renamedr->printf"%s"r.rename.``to``// the `` is needed here as to is a reserved word in F#
A tagged union is when each shape has a specific constructor. So in the F# version, the Locked is the tag for the LockedEvent, Labeled is the tag for the LabeledEvent, so on and so forth. In the Typescript example, we worked around it because the event property is on every TimelineEvent and is a different value.
If that wasn't true, then we would had to have added a field to TimelineEvent (typically called kind or tag) that would help us differentiate between the various shapes.