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.