Nice topic! Of course it depends a bit on the type of code, but here are some ideas/things I do:.
Regular/disciplined/consistent process, code review, cleanup, style and the like - all of these probably help. Code linting (hlint, stan..) and formatting tools (ormolu, fourmolu..) might be useful.
Beyond type-related errors, don’t forget to eliminate partial code - things that compile successfully but can fail at runtime. Linting tools and/or ghc -Wall can help detect some of these. Some examples:
- use of partial functions in your code (
head, !!, maximum, fromJust, error, read etc)
- use of partial functions in your dependencies
- printf formatting strings with mismatched arguments
- assuming that regular expressions generated or received at runtime are well formed
- working with text without handling decoding, wrong or missing system locale, etc.
For testing (via) console output, I like shelltestrunner.
For testing or at least monitoring performance, I like to log one line of performance measurements (time & memory) frequently to a persistent log, eg each time functional tests are run. Also, quickbench for quick ad-hoc comparisons.
For cross platform testing, github workflows make it relatively easy to test on the big ones.
There’s no substitute for real world testing by many users.
And, anything you can do to speed up the process of finding bugs, fixing, and getting the fixed version into the hands of users increases the perceived reliability (for the users that didn’t hit the bug).
One way to speed up the finding part, other than having many adventurous users, is to incentivise bug reports, eg with bounties.
One way to speed up the fixing part, other than having many responsive developers, is to have optional detailed debug output available.
QuickCheck inspired a whole family of similar tools (hedgehog, smallcheck etc.) - they’re probably all worth checking out. More generally, any kind of fuzz testing, including with external tools, seems useful.
Another important approach is simulation, allowing you to test your system in specific conditions and in accelerated time (like the TigerBeetle people).
LiquidHaskell (as you mentioned) adds powerful contracts, augmenting the Haskell type system.
Generating Haskell code from a language which allows more formal proof (like the Cardano people) is probably another good technique.