// 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()); } ); };