That's one of the hardest debugging jobs. Personally, my answer is: don't. Use shell only for scripts that they are so short that they obviously have no bugs. And that are so short-lived, after they've done their job (about 3 minutes in), you delete them; like that they can't grow bugs in the future. Why would working code grow bugs? Usually because it gets used again, but for a slightly different problem, one it wasn't really designed for.
Failing that, debugging shells scripts requires discipline. And I've worked with systems that have a quarter million lines of sh code, with the single largest one (single file!) being 18K lines. What you do is to break things apart into tiny little bits. Each shell function is a dozen or three dozen lines long. It is really well documented, with comments that describe exactly what it does, what the legal inputs are, and what it will output (return, or do as a side effect). Then you build a test battery, which is typically 3x longer than the shell code you are writing, which exercises each little function: make sure it can accept a wide variety of valid inputs, and make sure it cleanly rejects invalid input. Make sure it has the correct output for valid inputs. Ideally, the test battery is written by an independent person, who is an adversary, and tries to find bugs. Typically, for every software engineer (script writer), you need to hire two testers / test engineer. Then you need some test automation. That can for example be a script that runs all the tests, one at a time, and makes sure none fail.
One thing that really helps is being super consistent. For example, have one set of environment variables that turns debugging off and on, with relatively fine granularity. Make sure temporary files are in a consistent place, but with names that consistently are different, so different pieces of code can't step on each other's files. Have clear naming convention for variables. Make sure no code pollutes global name spaces: If a function sets a temporary variable, it must unset it before existing (set | wc -l, check before and after). Think of scripting as working on a workbench: After every operation, you must put the tools back where they belong, you must put all garbage in the trash, you must leave the work surface as clean as you found it, except perhaps with one more intermediate product stacked in the back.
Make a very clear list of available tools: We will run on V7 Bourne shell, using SysV awk, and BSD sed and grep, and absolutely nothing else (or whatever tools you have available). Then make sure ONLY those tools are available on the path. If the job needs something else (like gawk), the job simply won't get done. Or you declare gawk to be the new standard.
Now you have a battery of well tested small script parts. The rest is to assemble them into larger and larger towering edifices. That's doable by understanding the requirements of the job.