Monkey-Patching The $q Service With .fcall() In AngularJS
Yesterday, I looked at the pitfalls of starting an AngularJS promise-chain if the promise-generating method might throw an error. In that post, I solved the problem by wrapping the initial method inside a .then() callback; but, what I'd really like is a method akin to .fcall() in the Q-promise library. So, I wanted to see if I could monkey-patch the $q service, at runtime, to include a .fcall()-inspired method for function invocation.
Run this demo in my JavaScript Demos project on GitHub.
The concept behind .fcall() - at least in my demo - is that I want to start a promise chain by invoking a method that returns a promise; however, there's a chance that the initial method invocation will throw an error. In order to prevent that error from bubbling up, uncaught, I want to be able to catch it and translate it into a rejected promise. To do this, we defer to .fcall() to carry out the invocation in a protected context and ensure that a promise - either resolved or rejected - is returned.
My .fcall() method can take a variety of signatures:
- .fcall( methodReference )
- .fcall( methodReference, argsArray )
- .fcall( context, methodReference, argsArray )
- .fcall( context, methodName, argsArrray )
- .fcall( context, methodReference )
- .fcall( context, methodName )
The .fcall() method is going to be monkey-patched onto the $q service. In order to do that, we need to modify $q in a .run() block right after the AngularJS application is bootstrapped. This way, the modification will be available for any other component, within the application, that gets the $q service dependency-injected.
To see this in action, I'm starting a promise chain by calling loadSomething() with a set of arguments that will precipitate an error. This error will result in a promise that is rejected which will, in turn, cause my rejection handler to be invoked.
<!doctype html> <html ng-app="Demo"> <head> <meta charset="utf-8" /> <title> Monkey-Patching The $q Service With .fcall() In AngularJS </title> </head> <body ng-controller="AppController"> <h1> Monkey-Patching The $q Service With .fcall() In AngularJS </h1> <p> <em><storng>Note</strong>: This is not exactly the .fcall() method from Q. Rather, this is inspired by that concept.</em> </p> <!-- Load scripts. --> <script type="text/javascript" src="../../vendor/angularjs/angular-1.3.8.min.js"></script> <script type="text/javascript"> // Create an application module for our demo. var app = angular.module( "Demo", [] ); // -------------------------------------------------- // // -------------------------------------------------- // // I monkey-patch the .fcall() method into the root of the $q service. We have // to do this in a .run() block so that it will modify the $q service before any // other component in the application needs it. app.run( function monkeyPatchQService( $q, $exceptionHandler ) { // I invoke the given function using the given arguments. If the // invocation is successful, it will result in a resolved promise; if it // throws an error, it will result in a rejected promise, passing the // error object through as the "reason." // -- // The possible method signatures: // -- // .fcall( methodReference ) // .fcall( methodReference, argsArray ) // .fcall( context, methodReference, argsArray ) // .fcall( context, methodName, argsArrray ) // .fcall( context, methodReference ) // .fcall( context, methodName ) $q.fcall = function() { try { var components = parseArguments( arguments ); var context = components.context; var method = components.method; var inputs = components.inputs; return( $q.when( method.apply( context, inputs ) ) ); } catch ( error ) { // We want to pass the error off to the core exception handler. // But, we want to protect ourselves against any errors there. // While it is unlikely that this will error, if the app has // added an exception interceptor, it's possible something could // go wrong. try { $exceptionHandler( error ); } catch ( loggingError ) { // Nothing we can do here. } return( $q.reject( error ) ); } }; // --- // PRIVATE METHODS. // --- // I parse the .fcall() arguments into a normalized structure that is // ready for consumption. function parseArguments( args ) { // First, let's deal with the non-ambiguous arguments. If there are // three arguments, we know exactly which each should be. if ( args.length === 3 ) { var context = args[ 0 ]; var method = args[ 1 ]; var inputs = args[ 2 ]; // Normalize the method reference. if ( angular.isString( method ) ) { method = context[ method ]; } return({ context: context, method: method, inputs: inputs }); } // If we have only one argument to work with, then it can only be a // direct method reference. if ( args.length === 1 ) { return({ context: null, method: args[ 0 ], inputs: [] }); } // Now, we have to look at the ambiguous arguments. If w have // two arguments, we don't immediately know which of the following // it is: // -- // .fcall( methodReference, argsArray ) // .fcall( context, methodReference ) // .fcall( context, methodName ) // -- // Since the args array is always passed as an Array, it means that // we can determine the signature by inspecting the last argument. // If it's a function, then we don't have any argument inputs. if ( angular.isFunction( args[ 1 ] ) ) { return({ context: args[ 0 ], method: args[ 1 ], inputs: [] }); // And, if it's a string, then don't have any argument inputs. } else if ( angular.isString( args[ 1 ] ) ) { // Normalize the method reference. return({ context: args[ 0 ], method: args[ 0 ][ args[ 1 ] ], inputs: [] }); // Otherwise, the last argument is the arguments input and we know, // in that case, that we don't have a context object to deal with. } else { return({ context: null, method: args[ 0 ], inputs: args[ 1 ] }); } } } ); // -------------------------------------------------- // // -------------------------------------------------- // // I control the root of the application. app.controller( "AppController", function( $q ) { // Invoke the loadSomething() method with given arguments - .fcall() will // return a promise even if the method invocation fails. $q.fcall( loadSomething, [ 1, 2, 3 ] ) .then( function handleResolve( value ) { console.log( "Resolved!" ); console.log( value ); }, function handleReject( error ) { console.log( "Rejected!" ); console.log( error ); } ) ; // --- // PRIVATE METHODS. // --- // I load some data and return a promise. function loadSomething( a, b, c ) { // Using this special case to demonstrate the FAILURE path that // will raise an exception (to see if .fcall() can catch it). if ( ( a === 1 ) && ( b === 2 ) && ( c === 3 ) ) { throw( new Error( "InvalidArguments" ) ); } return( $q.when( "someValue" ) ); } } ); </script> </body> </html> When I invoke the loadSomething() method with arguments [1,2,3], it will throw an error. However, .fcall() will catch it, turn it into a rejected promise, and cause our rejection handler to be invoked. As such, when we run the above code, we get the following output:
The first line is the error being handed off to the core $exceptionHandler() service. The second line, however, is our rejection handler receiving the error-cum-rejected-promise.
While a method like .fcall() requires a different form of method invocation, I find it to be quite readable. It gets the job done and without all the cruft that my .then() approach had yesterday. Now, I can safely invoke promise-generating methods without the fear of uncaught exceptions.
Want to use code from this post? Check out the license.
Reader Comments
Ben,
For your demos - just include https://github.com/bahmutov/console-log-div script on your page (you can even do this through https://rawgit.com/bahmutov/console-log-div/master/console-log-div.js) to mirror console.log and console.error calls onto the page. Then you don't need to open browser console to show the results.
Ben,
It would be nice to have this patch as a stand alone bower / npm module, because I want to use it.
For this kind of monkey-patching, you can use a decorator: https://docs.angularjs.org/api/auto/service/$provide#decorator
Something like this is a reusable module you can drop into your projects:
appModule.config(['$provide', function($provide) {
$provide.decorator('$q', ['$delegate', function($delegate) {
$delegate.fcall = //...
return $delegate;
})];
}]);
This configures the option early on, and guarantees the functionality is available to all your other Angular services and components.
@Phil,
This is a great point. Tomasz Stryjewski was just recommending this on Twitter as well. I don't think I've ever used a decorator before. Actually, I believe I did a long time ago with HTTP request / response interceptors... but, if I recall correctly, that was basically copy/pasting from something I read.
The decorator looks like just the ticket. I'll definitely follow up with that exploration. Thanks!
@Gleb,
That's a really interesting idea, but it doesn't seem to play nicely with Firefox (probably because I have Firebug installed?). But, it seems to work in Chrome. Very cool!
@Ben,
Decorator pattern is definitely the way to go here ;-)
This is a very good demo Ben. Thanks for sharing.
@Tomasz, @Phil,
Thanks again for the feedback - decorators look pretty cool!
www.bennadel.com/blog/2775-monkey-patching-the-q-service-using-provide-decorator-in-angularjs.htm
To be honest, I've never really had a great mental model for what the configuration phase is and/or how "providers" work. I've used it a few times, but mostly using copy/paste/modify of other examples. Slowly, though, it's starting to become less hazy :D
@Ben,
Fixed the firefox (textContent property), all set.
@Gleb,
Confirmed fixed on my end as well.
I wonder if a lighter solution might be to use
$q.when().then(yourFn);
Invoking then should deliver all the exception handling goodness.