It is possible, you simply use preprocessor directives to add/remove the compatible/incompatible bits of code for the target device you are currently working on.
If you look at the source code for something like FreeBSD or Linux you will also find similar things going on in places - its impossible to write one piece of code to target 100% of available processors and architectures. Sometimes, things just have to be done a little differently.
As a more direct example, heres some code I wrote some years ago which does this to implement a simple I2C library across some PIC18 and PIC24 devices:
https://github.com/tomstorey/Microchip-PIC/blob/master/i2c_routines.cBetween families you arent going to ever get copy/paste compatible code - peripherals have different complexity and use different size registers for their configuration. Well, maybe you could keep similar bit, field and maybe register names, but there will still be other incompatibilites that you will have to cater for on a per device or family basis.
If you look at the manual for each of the different compilers (XC8, XC16 and XC32) you will find that each one defines a bunch of preprocessor variables that allow you to detect a whole host of environmental things, such as the device family, whether youre doing a debug or production build, even specific device models.
Yeah it can make the code look a little messy with lots of #if's sprinkled everywhere, but MPLAB X can at least grey out sections of code that dont match, and its really the only way you'll get close to implementing a "one size fits all" solution.
From there, the best thing you can do is not directly interface with peripherals throughout the rest of your code whenever you can avoid it, and simply call into your library. That way the application stays as generic as possible and easy to follow so you dont lose track of what it is trying to do, and the libraries are the things that can be all messed up. Theres always some kind of tradeoff.