Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save 0xdevalias/063ed7edafd735fd141b94c04ef38ed5 to your computer and use it in GitHub Desktop.

Select an option

Save 0xdevalias/063ed7edafd735fd141b94c04ef38ed5 to your computer and use it in GitHub Desktop.
Examples of zsh script patterns for exiting from nested functions — showing how exit inside helpers can kill your shell, why that happens, and safer alternatives using return, subshells, or encapsulated functions.

Zsh Script Patterns: Handling Nested Function Exits and Returns Without Killing Your Shell

This guide explores different ways to structure zsh scripts and functions when you want to exit early from inside a nested function. The main pitfall is that exit inside a sourced script or function doesn’t just stop the function — it can terminate your whole shell session. The examples here show common patterns people try, the problems they run into, and safer alternatives. Each section highlights what happens when you run or source the code, where things can go wrong, and why the simplest approach — a basic standalone script — is usually still the best.

Table of Contents

1. Simple Script (no special handling)

File: my_script_name.zsh

#!/usr/bin/env zsh

show_help() {
  echo "Help: running show_help"
  exit 0
}

error_exit() {
  echo "Error: $1" >&2
  exit 1
}

echo "Starting simple script"
show_help
echo "This should never run"

Behavior:

  • When executed (./my_script_name.zsh):
    Starting simple script
    Help: running show_help
    
    then exits with code 0.
  • When sourced (. my_script_name.zsh): exit kills the current shell session.

Issue: Not safe to source, since exit is used inside helper functions.

2. Export Function or Run When Executed

#!/usr/bin/env zsh

show_help() {
  echo "Help: doing help"
  exit 0
}

error_exit() {
  echo "Error: $1" >&2
  exit 1
}

_my_script_name() {
  echo "Some main content"
  show_help
  echo "This should never run"
}

# Run the main function if script is not sourced
if ! [[ "$ZSH_EVAL_CONTEXT" == *:file ]]; then
  _my_script_name "$@"
fi

Behavior:

  • Sourced: defines _my_script_name, show_help, error_exit, but does not run.
  • Executed: runs _my_script_name immediately.

Issues:

  • Unsafe to source, because show_help and error_exit both call exit. If another script sources this one and then calls _my_script_name, an exit inside these helpers will terminate the caller’s shell.
  • Leaks the helper functions (show_help and error_exit) into the global namespace. They can be called directly, but if their names clash with other functions or commands, problems will occur.

Workaround:

  • A caller could safely run it in a subshell:
    ( _my_script_name )
    ensuring any exit calls only terminate the subshell.

2.5. Encapsulated Helpers (no global leaks)

#!/usr/bin/env zsh

_my_script_name() {
  show_help() {
    echo "Help: doing help"
    exit 0
  }

  error_exit() {
    echo "Error: $1" >&2
    exit 1
  }

  echo "Some main content"
  show_help
  echo "This should never run"
}

# Run the main function if script is not sourced
if ! [[ "$ZSH_EVAL_CONTEXT" == *:file ]]; then
  _my_script_name "$@"
fi

Behavior:

  • Sourced: defines only _my_script_name, not the helpers.
  • Executed: runs _my_script_name immediately.

Notes:

  • By defining show_help and error_exit inside _my_script_name, we avoid polluting the global namespace. Only _my_script_name is exposed.
  • Still unsafe to source if exit calls are triggered by helpers, but avoids the name clash problem.

Workaround:

  • A caller could safely run it in a subshell:
    ( _my_script_name )
    ensuring any exit calls only terminate the subshell.

3. Function with Embedded Subshell

_my_script_name() {
  # Wrap the entire script in a subshell so we can use exit cleanly
  (
    show_help() {
      echo "Help: doing help"
      exit 0
    }

    error_exit() {
      echo "Error: $1" >&2
      exit 1
    }

    echo "Some main content"
    show_help
    echo "This should never run"
  )
  return $?
}

alias my_script_name="_my_script_name"

Behavior:

  • Can be run directly in the terminal without causing it to exit.
  • Entire function runs in a subshell, so exit only terminates the subshell.
  • Parent shell continues running, with return code propagated.

Notes:

  • This can be a useful hack if you want to define a more complex “script-like” function to be used in an alias but don’t want to make it an actual script.
  • But seriously: save yourself hours of headaches, hacks, and workarounds — don’t try and do things like this example, just use a proper script. You’ll thank yourself later.

Caveat:

  • Variable changes inside the subshell are lost (not visible to parent).

4. Function Using ERR_RETURN

_my_script_name() {
  setopt localoptions ERR_RETURN

  show_help() {
    echo "Help: doing help"
    return 1   # must be non-zero to trigger ERR_RETURN
  }

  error_exit() {
    echo "Error: $1" >&2
    return 2
  }

  echo "Some main content"
  show_help
  echo "This should never run"
}

alias my_script_name="_my_script_name"

Behavior:

  • ERR_RETURN causes any non-zero return to immediately exit the function (similar to set -e but scoped to the function).
  • show_help now returns an error code instead of exiting. Returning 1 triggers ERR_RETURN and halts the function early.

Notes:

  • Unlike earlier examples, exit has been swapped for return with error codes. This avoids killing the whole shell and makes the function safe to run inline.
  • For this to work, show_help cannot return 0. It must return a non-zero value to trigger the ERR_RETURN behavior.
  • As with the subshell hack above: this can be useful if you want “script-like” behavior in a function/alias.
  • But seriously: save yourself hours of headaches, hacks, and workarounds — don’t try and do things like this example, just use a proper script. You’ll thank yourself later.

Recommendations

  • Strongly recommend just using pattern 1 (a basic script) for most cases. It is the simplest, safest, and least surprising approach.
  • If you really need to be able to call it both as a script or source it, then maybe pattern 2.5 is fine, since it avoids polluting the global scope.
  • Pattern 4 is kind of hacky, but if for some reason you can’t use a normal script and must do it only as a function, it’s probably less hacky than pattern 3. But seriously… just don’t.
  • Pattern 3 is also hacky, though it does technically work. But again — seriously, just don’t.

But seriously, tying this back to the strong recommendation above: stop trying to think of esoteric clever ways to do things, or being lazy and hacking a function+alias into a full-blown script. Just create a new script in your bin directory so you don’t waste a day chasing esoteric rabbit holes only to end up back at the most obvious simple solution still being the best.

Trust me. Seriously.. Just do the basic shell script.. Save yourself.. I beg you!

Caveat

I don’t claim that everything here is perfectly accurate, complete, or the best possible way to handle these patterns. There may be cleaner workarounds, safer idioms, or zsh features I’ve overlooked. This write-up is simply my attempt to capture what I learned after burning a day chasing hacks, dead ends, and confusing behavior around exit inside nested functions. My goal is to document the trade-offs and pitfalls clearly enough that I (and hopefully others) don’t have to waste that time again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment