Skip to content

Instantly share code, notes, and snippets.

@dtipson
Last active August 13, 2024 18:34
Show Gist options
  • Save dtipson/01fba81f3ba19daa6da5bd809629990e to your computer and use it in GitHub Desktop.
Save dtipson/01fba81f3ba19daa6da5bd809629990e to your computer and use it in GitHub Desktop.
// 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