Last active
August 13, 2024 18:34
-
-
Save dtipson/01fba81f3ba19daa6da5bd809629990e to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Finally wrapped your head around Promises? Time to toss out all that knowledge and learn the functional alternative! | |
// Here's a super simple implementation of a Task "type" | |
const __Task = fork => ({fork}) | |
// Absurdly simple! All we're doing is using a function that returns some unknown value, by name, in an object. | |
// At this point "fork" is just a cute name though: what matters is how we use it. | |
// What makes Task a "Task" is that is that the "fork" value here... will be a higher-order function. | |
// Here's a usage example, already fully functional, already nearly as powerful as Promise! | |
const delayedFive = __Task( | |
resolve => setTimeout(resolve, 500, 5) | |
) | |
delayedFive.fork(x => console.log(x));//returns cancelation id, but logs 5 after 500ms | |
// That's basically equivalent to: | |
const eventuallyFive = new Promise(resolve => setTimeout(resolve, 500, 5)).then(x => console.log(x)); | |
// But here's a critical difference: that line above, all by itself, will log 5 after a delay | |
// That's NOT the case with delayedFive: we had to separately run the .fork function to make it do anything. | |
// Before we go further, let's also acknowledge that Future-y/Task-y/Promise-y types are generally built to | |
// handle a side-effect which can succeed OR fail. | |
// So here's a more complete usage of Task, wherein the fork function takes TWO arguments, first a function to | |
// handle errors, the second to handle a success. In this example, we're creating an effect that, | |
// When forked, with either log normally OR log an error, randomly, after the delay: | |
const delayedBadFive = __Task( | |
(reject, resolve) => Math.random() < 0.5 ? setTimeout(reject, 500, 5) : setTimeout(resolve, 500, 5) | |
) | |
delayedBadFive.fork(e=>console.log(`delayed error: ${e}`), x=>console.log(`delayed success: ${x}`)); | |
// Again, creating delayedBadFive doesn't DO anything itself. It just stores an operation to be run later. | |
// And so: the core of what makes Tasks different from Promises is that they're both pure & lazy. | |
// What that means we're defining the logic of the operation separately from | |
// running the impure effect (which Promises muddle, running the impure Promise constructor function immediately) | |
// With Task you can create it at any point, save it for later, and then when you run it, | |
// you'll get back whatever synchronous value or function the constructor returns and then LATER trigger behavior | |
// based on some asynchronous effect. | |
// This temporal bifrucation makes exposing control interfaces for the effect (like the cancelation of a http request) | |
// much more straightforward, as the constructor's return value is exposed directly and synchronously | |
// right where you called it. The final side-effect is separate and subsequent, happening in the future. | |
// (In fact, Tasks are sometimes called "Futures," though some reserve those terms for different sorts of things). | |
// Of course, if you've used Promises a lot, you're probably wondering what the equivalent to ".then" is. Well, | |
// there are two answers to that question. One is that you've already seen it, in a sense. The functions you pass to | |
// .fork ARE the equalivents to .then(success, error), just with the argument order reversed | |
// (error handling function first, then a success handler). | |
// But of course, the utility of Promises is in part that they can chain multiple .then operations, one after the other. | |
// A Promise is in some sense a lot like an Array containing a single value, but smeared out over time. That is to say | |
// you can Promise.resolve(5).then(fn1).then(f2).then(f3) in the same way you can do [5].map(fn1).map(fn2).map(fn3) | |
// Are Tasks capable of this? As it happens, they are. They are because this ability to take some type and modify its | |
// "inner" value in some way via a function is deeply connected. Promise.resolve(5).then(x=>x+1) is, in fact, | |
// exactly the same sort of operation as [5].map(x=>x+1)! You're taking a value inside a type and transforming it into the | |
// same type, but with a different value. Any type that can do this is, with certain restrictions, a Functor. In fact, | |
// you could just define Promise.prototype.map as an alias of Promise.prototype.then and use it instead of .then | |
// any time you have a callback that simply returns a new value. | |
// So let's make a more usable version of Task that's also a Functor (which is to say, give it .map capability) | |
const _Task = fork => ({ | |
fork, | |
map: fn => _Task( | |
(reject, resolve) => fork(reject, x=> resolve(fn(x)) ) | |
) | |
}); | |
// Task.map here takes a single argument function that, when called on a Task, returns a new Task. | |
// But it does it in a particularly trisky way that exploits the original Task's fork function. | |
// This .map operation on a Task quietly hooks into the original Task it's based on, hijacks its fork function, | |
// and hooks the addition transformation function into the final result. | |
// Functors of course, only work on the success route. If the original fork function rejects, then .map here just | |
// returns that rejection along without any modification. The success route allows you access to the eventual value for | |
// a further calculation. With the reject route, there's no point, since you got an error instead of a value, so | |
// there's no point in continuing the logical chain: you just fast forward to whatever you set up to handle errors. | |
// Now, before using .map, let's also give Tasks a simple way to lift a simple value into the minimal context... | |
_Task.of = x => _Task( (a, b) => b(x) ); | |
// This method, .of, is basically similar to doing Promise.resolve(4) | |
// It just gives you a cheap and easy way to put a value inside the type, just like Array.of does for Arrays. | |
// For now, that mostly just makes it a lot easier to see how Task.map works, in action | |
const taskOfSix = _Task.of(5).map( x => x+1 );// returns a forkable Task | |
taskOfSix.fork(e => console.log(e), x => console.log(x))//synchronously logs 6 | |
// We made Task into a Functor. From here, it might start to be clear how to make a version that's a Monad. | |
// If you don't actually know what a Monad is, no worries: for our purposes it's a method of Task that allows you | |
// to supply a function that will return another Task, like a request for a second api response, | |
// calling that second api using data from the first. But instead of ending up with a Task within a Task, Monads | |
// smartly flatten things out so that you just end up with another Task containing another eventual value. | |
// If none of that made sense, well what I mean is just this sort of pattern, which we do with Promises all the time: | |
// fetch('/api1').then( result => fetch(`/api2?value=${result}`)); | |
// The result of that operation isn't a Promise containing a Promise, it's instead just a Promise with the result | |
// from the second api call. Promises auto-flatten, guessing that if the result of calling the function given to | |
// .then is another Promise, the new Promise you get back should just wait and return THAT new Promise's result. | |
// This is yet another thing that Promises muddle: by auto-detecting the result of the function and behaving | |
// differently depending, they confuse .map with, well, .flatMap (which really should be called .mapFlat, but whatever). | |
// Arrays don't behave this way. [5].map(x=>[x]) results in [[5]], not [5]. And Promises not behaving like Arrays do | |
// makes them, well, weird. It prevents you from being able to ignore the difference between an Array and Promise | |
// and have to invent special logic for each case. | |
// Tasks won't do that. They'll have separate methods that match the behavior of Arrays, which on a deeper level means | |
// that you can write a whole host of functions that are useful irregardless of whether they're using Tasks or Arrays. | |
// In javascript, it seems like Arrays will soon get a native .flatMap method. If so, Tasks can as well. For the time being | |
// though, we'll call this method .chain, since that's the convention in FP Javascript world. But .chain and .flatMap, if | |
// defined properly, are just different names for the same thing: Task can just alias .chain to .flatMap or vice-versa. | |
// So anyhow, how would this Monadic .chain method be constructed for a Task? Here's a hint: .fork is going to get | |
// called twice. Why? Well, the implementation of .map uses fork once to "continue" the Task logic that passes along | |
// a value that doesn't yet exist. Calling fork "unwraps" the inner value so we can transform it: so in the case of | |
// transforming a nested Task, we'll have to "unwrap" things twice. | |
const Task = fork => ({ | |
fork, | |
map: fn => Task( | |
(reject, resolve) => fork(reject, x=> resolve(fn(x)) ) | |
), | |
chain: fn => Task( | |
(reject, resolve) => { | |
return fork( | |
a => reject(a), | |
b => fn(b).fork(reject, resolve) | |
) | |
} | |
) | |
}); | |
Task.of = x => Task( (a, b) => b(x) );// just adding this .of method into the latest example version | |
// Now let's walk through how this works by building up some functional logic | |
const usernameTask = Task.of('Myusername');//just an example place to start with a value | |
const fakeApi1 = username => Task((e, s) => setTimeout(s, 500, `${username}:4%2hSfds&5@h`)); | |
const fakeApi2 = tokenstring => Task((e, s) => setTimeout(s, 500, `${tokenstring}, logged in`)); | |
const getUser = usernameTask | |
.map(str => String(str).toLowerCase()) | |
.chain(fakeApi1) | |
.chain(fakeApi2); | |
// Nice: now we have a pure description of an operation that's composed of tiny bits, each of which is | |
// testable on its own. Note that any errors in these functions directly do NOT become rejections, | |
// they will JUST throw errors, breaking the program at the outer level instead of handling it internal to the | |
// resolve/reject interface. | |
// And that's a feature, not a bug. There's a critical difference between an error of logic/handling functional | |
// type signatures safely and correctly, and errors thrown from side effects. | |
// (That is, from an api failing vs. you writing a program that tries to .toUpperCase an Integer) | |
// Task forces us to handle those two types of errors differently, one accounting for failures in side-effects, the | |
// other accounting for testable, fixable errors in program logic. But we're getting too high level maybe. | |
// Let's just let's use it and see what happens: | |
getUser.fork( e => console.error(e), x => console.log(x) );// logs "Myusername:4%2hSfds&5@h, logged in" | |
// Let's note another cool thing going on here. getUser is actually a complete chain of functional logic all stored in a | |
// single value. But we can always take that and extend it further, and assign THAT extension to a variable, and so on. | |
// We can call either Task's fork any time we want OR we can modify that logic further and assign the modification | |
// to a new value, and keep both chains of logic around for different purposes. | |
// Also, let's still note that this entire operation, when forked, returns a cancelation id | |
// which we could then use to cancel the requests before they ever even complete | |
const cancelID = getUser.fork( e => console.error(e), x => console.log(x) ); clearTimeout(cancelID); | |
// You might see a problem with the cancelation logic here: there are two cancelation IDs being | |
// created in this whole process, and we're only getting one at the end, so depending on WHEN you call the cancelation | |
// it might not be the right/currently active one. There's a way around this (and, surprise, it requires turning | |
// the returned cancelation operation into a function) but it's just worth noting for now. The same problem exists, just | |
// in a worse and more obscure way, with Promises and their overly elaborate cancelationToken process | |
// For now though, we've basically covered basically everything that Promises do in a (hopefully) pretty easy | |
// to understand functional alternative way. It's worth noting that an actual implementation would define Task | |
// itself as a function with a prototype. Here's just a taste of that: | |
const pTask = function(fork){ | |
if (!(this instanceof Task)) { | |
return new Task(fork); | |
} | |
this.fork = fork | |
}; | |
pTask.of = Task.prototype.of = x => new Task((a, b) => b(x), `=> ${x}`); | |
pTask.prototype.map = function _map(f) { | |
return new Task( | |
(left, right) => this.fork( | |
a => left(a), | |
b => right(f(b)) | |
) | |
); | |
}; | |
pTask.prototype.chain = function _chain(f) { | |
return new Task( | |
(left, right) => { | |
let cancel; | |
let outerFork = this.fork( | |
a => left(a), | |
b => { | |
cancel = f(b).fork(left, right); | |
} | |
); | |
return cancel ? cancel : (cancel = outerFork, x => cancel()); | |
} | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment