Writing Reusable Libraries

Chapter 10 — Writing Reusable Libraries

A script that works once is useful. A library of well-designed functions that any script can source is an asset. This chapter covers how to structure Bash libraries so they are safe to source, self-documenting, version-aware, and easy to distribute — turning a collection of helper functions into something you can genuinely depend on.

1 — The Source-vs-Execute Split

Every library file needs to answer two questions: Am I being sourced or run directly? and Has something already sourced me?

The guard pattern: source-once

#!/usr/bin/env bash
# lib/logging.sh — safe to source multiple times

# Guard: skip re-sourcing if already loaded
[[ -n "${_LIB_LOGGING_LOADED:-}" ]] && return 0
readonly _LIB_LOGGING_LOADED=1

# … function definitions follow …

The return 0 exits the sourcing operation cleanly. If the file is executed directly (no sourcing parent), return would fail — that's actually what you want as a hard stop for pure libraries. For libraries that also have a self-test mode, use the BASH_SOURCE trick instead:

# At the bottom of the library file
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  # Script is being run directly — run self-tests
  _lib_logging_self_test
fi
BASH_SOURCE[0] is the path of the current file. ${0} is the name of the running script. They are equal only when the file is executed directly — when sourced, ${0} remains the caller's name.

2 — Semantic Versioning Guards

When scripts depend on a specific version of a library, they should be able to assert compatibility at load time.

# lib/utils.sh
[[ -n "${_LIB_UTILS_LOADED:-}" ]] && return 0
readonly _LIB_UTILS_LOADED=1

readonly LIB_UTILS_VERSION='2.4.1'
readonly LIB_UTILS_VERSION_MAJOR=2
readonly LIB_UTILS_VERSION_MINOR=4

# Callers use this function to declare their minimum requirement
lib_utils_require() {
  local req_major="${1:?major version required}"
  local req_minor="${2:-0}"

  if (( LIB_UTILS_VERSION_MAJOR != req_major )); then
    printf 'ERROR: lib/utils.sh major version mismatch (have %s, need %d.x)\n' \
      "$LIB_UTILS_VERSION" "$req_major" >&2
    return 1
  fi

  if (( LIB_UTILS_VERSION_MINOR < req_minor )); then
    printf 'ERROR: lib/utils.sh too old (have %s, need %d.%d+)\n' \
      "$LIB_UTILS_VERSION" "$req_major" "$req_minor" >&2
    return 1
  fi
}

# In a caller script:
# source lib/utils.sh
# lib_utils_require 2 3 || exit 1   # need at least 2.3

3 — Namespace Discipline

Bash has a single global namespace for functions and variables. Libraries must be deliberate about what they export to avoid collisions with caller scripts.

Naming conventions

Symbol typeConventionExample
Public functionlibname_verb_nounlog_info, http_get
Private function_libname_verb_noun_log_format_msg
Public constantLIBNAME_CONSTANT (readonly)LOG_LEVEL_DEBUG
Private variable_LIBNAME_VAR_LOG_FD
Guard variable_LIB_NAME_LOADED_LIB_HTTP_LOADED

Containing side effects with local

# Bad — _tmp leaks into the caller's scope
bad_function() {
  _tmp="$(some_command)"
  # …
}

# Good — everything stays in local scope
good_function() {
  local _tmp
  _tmp="$(some_command)"
  # …
}

# Returning values without subshells: use a nameref
str_upper() {
  local -n _result_ref="${1:?nameref var required}"
  local _input="$2"
  _result_ref="${_input^^}"   # Bash 4+ case conversion
}

# Caller:
declare upper
str_upper upper "hello world"
echo "$upper"   # HELLO WORLD — no subshell, no fork
Nameref collision hazard: the nameref variable name and the caller's variable name must differ. If a caller passes _result_ref as the variable name, the nameref binds to itself. Using a double-underscore prefix (__result) reduces collision probability.

4 — Auto-Documentation with Structured Comments

Bash has no built-in doc system, but a consistent comment format makes it easy to generate reference docs with grep or awk.

##
## log_info MESSAGE [CONTEXT...]
##   Write an INFO-level log entry to stderr.
##   MESSAGE  — the primary log message
##   CONTEXT  — optional key=value pairs appended after the message
##
## Example:
##   log_info "User logged in" user=alice ip=10.0.0.1
##
log_info() {
  _log_write 'INFO' "$@"
}

Extracting docs at runtime

# Extract all ## doc blocks and their function names from a library
lib_help() {
  local libfile="${1:?library file required}"
  awk '/^##/{
    sub(/^## ?/, "")
    doc = doc $0 "\n"
    next
  }
  /^[a-zA-Z_][a-zA-Z0-9_]*\(\)/{
    if (doc != "") { print "--- " $0; printf "%s", doc; print "" }
    doc = ""
    next
  }
  { doc = "" }' "$libfile"
}

5 — declare -f Introspection

declare -f prints the definition of a loaded function. declare -F lists all defined function names. These are invaluable for debugging, mocking in tests, and building dynamic dispatch.

# List all functions defined by a library after sourcing it
before=$(declare -F)
source lib/http.sh
after=$(declare -F)
comm -13 <(sort <<<"$before") <(sort <<<"$after")   # new functions

# Print the source of a specific function
declare -f http_get

# Check if a function exists before calling it
func_exists() { declare -f "$1" >/dev/null 2&1; }

if func_exists http_retry; then
  http_retry "$url"
else
  http_get "$url"
fi

Dynamic dispatch via function name construction

# A plugin pattern — call handler_TYPE if it exists, else default
dispatch() {
  local type="$1"; shift
  local handler="handle_${type}"

  if func_exists "$handler"; then
    "$handler" "$@"
  else
    printf 'No handler for type: %s\n' "$type" >&2
    return 1
  fi
}

handle_json() { echo "Handling JSON: $*"; }
handle_csv()  { echo "Handling CSV: $*"; }

dispatch json data.json   # → handle_json data.json
dispatch xml  data.xml    # → No handler for type: xml

6 — A Complete Library Template

Here is a production-ready skeleton you can copy as a starting point for any new library:

#!/usr/bin/env bash
# =============================================================================
# lib/logging.sh — structured log helpers
# Version: 1.0.0  |  Requires: bash >= 4.2
# =============================================================================

# Source guard
[[ -n "${_LIB_LOGGING_LOADED:-}" ]] && return 0
readonly _LIB_LOGGING_LOADED=1

# Bash version check
if (( BASH_VERSINFO[0] < 4 || ( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2 ) )); then
  printf 'ERROR: lib/logging.sh requires bash >= 4.2 (have %s)\n' \
    "$BASH_VERSION" >&2
  return 1
fi

# --- Public constants --------------------------------------------------------
readonly LIB_LOGGING_VERSION='1.0.0'
readonly LOG_LEVEL_DEBUG=0
readonly LOG_LEVEL_INFO=1
readonly LOG_LEVEL_WARN=2
readonly LOG_LEVEL_ERROR=3

# --- Private state -----------------------------------------------------------
_LOG_LEVEL="${LOG_LEVEL:-$LOG_LEVEL_INFO}"   # caller may set LOG_LEVEL
_LOG_FILE="${LOG_FILE:-}"                    # empty = stderr only
_LOG_COLOR="${LOG_COLOR:-auto}"              # auto | always | never

# Resolve colour mode once at load time
if [[ $_LOG_COLOR == 'auto' ]]; then
  [[ -t 2 ]] && _LOG_COLOR='always' || _LOG_COLOR='never'
fi

# ANSI colours
if [[ $_LOG_COLOR == 'always' ]]; then
  _C_DEBUG='\e[0;36m'   # cyan
  _C_INFO='\e[0;32m'    # green
  _C_WARN='\e[0;33m'    # yellow
  _C_ERROR='\e[0;31m'   # red
  _C_RESET='\e[0m'
else
  _C_DEBUG='' _C_INFO='' _C_WARN='' _C_ERROR='' _C_RESET=''
fi

# --- Private helpers ---------------------------------------------------------
_log_write() {
  local level_name="$1"; shift
  local level_num="$1"; shift
  (( level_num < _LOG_LEVEL )) && return 0

  local color_var="_C_${level_name}"
  local ts="$(date '+%Y-%m-%dT%H:%M:%S')"
  local line
  printf -v line '%s[%s] [%s] %s%s' \
    "${!color_var}" "$ts" "$level_name" "$*" "$_C_RESET"

  printf '%s\n' "$line" >&2
  [[ -n "$_LOG_FILE" ]] && printf '%s\n' "$line" >> "$_LOG_FILE"
}

# --- Public API --------------------------------------------------------------
##
## log_debug MESSAGE
##   Write a DEBUG entry (visible when LOG_LEVEL=0)
##
log_debug() { _log_write DEBUG "$LOG_LEVEL_DEBUG" "$@"; }

##
## log_info MESSAGE
##
log_info()  { _log_write INFO  "$LOG_LEVEL_INFO"  "$@"; }

##
## log_warn MESSAGE
##
log_warn()  { _log_write WARN  "$LOG_LEVEL_WARN"  "$@"; }

##
## log_error MESSAGE
##   Write an ERROR entry and return exit code 1
##
log_error() { _log_write ERROR "$LOG_LEVEL_ERROR" "$@"; return 1; }

##
## log_set_level LEVEL
##   Set minimum log level: 0=DEBUG 1=INFO 2=WARN 3=ERROR
##
log_set_level() { _LOG_LEVEL="${1:?level required}"; }

##
## log_set_file PATH
##   Tee all log output to PATH in addition to stderr
##
log_set_file() { _LOG_FILE="${1:?path required}"; }

# --- Self-test ---------------------------------------------------------------
_lib_logging_self_test() {
  echo "=== lib/logging.sh self-test ==="
  log_debug "This is DEBUG"
  log_info  "This is INFO"
  log_warn  "This is WARN"
  log_error "This is ERROR" || true
  log_set_level "$LOG_LEVEL_WARN"
  log_info  "You should NOT see this"
  log_warn  "You should see this"
  echo "=== PASS ==="
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  _lib_logging_self_test
fi

7 — Distribution and Sourcing Strategies

Portable sourcing: finding the library relative to the script

#!/usr/bin/env bash
# Works whether the script is on PATH, symlinked, or called by absolute path
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
source "$SCRIPT_DIR/../lib/logging.sh"
source "$SCRIPT_DIR/../lib/utils.sh"
pwd -P resolves symlinks. This ensures the path is absolute and canonical even when the script is invoked via a symlink in /usr/local/bin.

A loader function for larger projects

# lib/loader.sh — source multiple libraries with error checking
_LIB_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)

lib_load() {
  local lib
  for lib in "$@"; do
    local path="$_LIB_ROOT/${lib}.sh"
    if [[ ! -f "$path" ]]; then
      printf 'lib_load: library not found: %s\n' "$path" >&2
      return 1
    fi
    # shellcheck source=/dev/null
    source "$path" || { printf 'lib_load: failed to source %s\n' "$path" >&2; return 1; }
  done
}

# Usage in a script:
# source /opt/myapp/lib/loader.sh
# lib_load logging utils http   # sources logging.sh, utils.sh, http.sh

Single-file distribution via bundling

# bundle.sh — concatenate all library files into one distributable
# Strip duplicate shebangs and source guards, keeping functions
{
  echo '#!/usr/bin/env bash'
  echo '# Generated bundle — do not edit manually'
  for lib in lib/*.sh; do
    echo; echo "# ── $lib ──"
    # Remove shebang lines from non-first files
    grep -v '^#!' "$lib"
  done
} > dist/myapp_bundle.sh
chmod +x dist/myapp_bundle.sh

8 — Testing Your Library

A library without tests is a library you'll break silently. Even a minimal inline self-test loop is better than nothing.

# Minimal test runner — no external dependencies
_TESTS_RUN=0
_TESTS_FAILED=0

assert_eq() {
  local desc="$1" expected="$2" actual="$3"
  (( _TESTS_RUN++ ))
  if [[ "$expected" == "$actual" ]]; then
    printf '  PASS: %s\n' "$desc"
  else
    printf '  FAIL: %s\n    expected: %q\n    actual:   %q\n' \
      "$desc" "$expected" "$actual"
    (( _TESTS_FAILED++ ))
  fi
}

assert_exit() {
  local desc="$1" expected_code="$2"
  shift 2
  (( _TESTS_RUN++ ))
  "$@"; local rc=$?
  if (( rc == expected_code )); then
    printf '  PASS: %s (exit %d)\n' "$desc" "$rc"
  else
    printf '  FAIL: %s (expected exit %d, got %d)\n' \
      "$desc" "$expected_code" "$rc"
    (( _TESTS_FAILED++ ))
  fi
}

test_summary() {
  printf '\nResults: %d/%d passed\n' \
    "$(( _TESTS_RUN - _TESTS_FAILED ))" "$_TESTS_RUN"
  (( _TESTS_FAILED == 0 ))   # exit 0 on success
}

# Example usage
assert_eq "str_upper converts hello" "HELLO" "$(str_upper <<< 'hello')"
assert_exit "log_error returns 1" 1 log_error "test error"
test_summary

Exercises

Exercise 1 — lib/validate.sh

Write a library lib/validate.sh with the following public functions, each returning exit 0 on success and printing an error message on failure:

  • validate_nonempty VAR_NAME VALUE — fails if VALUE is empty
  • validate_integer VAR_NAME VALUE — fails if VALUE is not a whole number
  • validate_file VAR_NAME PATH — fails if PATH does not exist as a readable file
  • validate_dir VAR_NAME PATH — fails if PATH is not a directory

Include source guard, version constant, and a self-test that runs when executed directly.

#!/usr/bin/env bash
# lib/validate.sh — input validation helpers  v1.0.0

[[ -n "${_LIB_VALIDATE_LOADED:-}" ]] && return 0
readonly _LIB_VALIDATE_LOADED=1
readonly LIB_VALIDATE_VERSION='1.0.0'

_validate_fail() {
  printf 'validate: %s: %s\n' "$1" "$2" >&2
  return 1
}

##
## validate_nonempty VAR_NAME VALUE
##
validate_nonempty() {
  [[ -n "${2:-}" ]] || _validate_fail "$1" "must not be empty"
}

##
## validate_integer VAR_NAME VALUE
##
validate_integer() {
  [[ "${2:-}" =~ ^[0-9]+$ ]] || \
    _validate_fail "$1" "'${2:-}' is not a non-negative integer"
}

##
## validate_file VAR_NAME PATH
##
validate_file() {
  [[ -f "${2:-}" && -r "${2:-}" ]] || \
    _validate_fail "$1" "'${2:-}' is not a readable file"
}

##
## validate_dir VAR_NAME PATH
##
validate_dir() {
  [[ -d "${2:-}" ]] || _validate_fail "$1" "'${2:-}' is not a directory"
}

_lib_validate_self_test() {
  local pass=0 fail=0
  _chk() {
    if "$@" 2>/dev/null; then (( pass++ )); else (( fail++ )); fi
  }
  _nochk() {
    if ! "$@" 2>/dev/null; then (( pass++ )); else (( fail++ )); fi
  }
  _chk   validate_nonempty X "hello"
  _nochk validate_nonempty X ""
  _chk   validate_integer  N "42"
  _nochk validate_integer  N "abc"
  _chk   validate_file     F /etc/hostname
  _nochk validate_file     F /no/such/file
  _chk   validate_dir      D /tmp
  _nochk validate_dir      D /etc/hostname
  printf 'validate self-test: %d pass, %d fail\n' "$pass" "$fail"
  (( fail == 0 ))
}

[[ "${BASH_SOURCE[0]}" == "${0}" ]] && _lib_validate_self_test

Exercise 2 — Nameref return values

Write a library lib/strings.sh using nameref (local -n) for all return values — no subshells. Include:

  • str_trim RETVAR INPUT — strip leading and trailing whitespace
  • str_repeat RETVAR STR N — repeat STR N times
  • str_join RETVAR SEP ARRAY_NAME — join array elements with SEP
#!/usr/bin/env bash
# lib/strings.sh  v1.0.0

[[ -n "${_LIB_STRINGS_LOADED:-}" ]] && return 0
readonly _LIB_STRINGS_LOADED=1

##
## str_trim RETVAR INPUT
##
str_trim() {
  local -n __str_trim_ret="$1"
  local __s="$2"
  __s="${__s#"${__s%%[![:space:]]*}"}"   # strip leading
  __s="${__s%"${__s##*[![:space:]]}"}"   # strip trailing
  __str_trim_ret="$__s"
}

##
## str_repeat RETVAR STR N
##
str_repeat() {
  local -n __str_repeat_ret="$1"
  local __str="$2" __n="$3" __acc=''
  local __i
  for (( __i=0; __i<__n; __i++ )); do
    __acc+="$__str"
  done
  __str_repeat_ret="$__acc"
}

##
## str_join RETVAR SEP ARRAY_NAME
##   ARRAY_NAME is the name of an existing array variable
##
str_join() {
  local -n __str_join_ret="$1"
  local __sep="$2"
  local -n __arr="$3"
  local __out='' __elem
  for __elem in "${__arr[@]}"; do
    [[ -n $__out ]] && __out+="$__sep"
    __out+="$__elem"
  done
  __str_join_ret="$__out"
}

[[ "${BASH_SOURCE[0]}" == "${0}" ]] && {
  declare result
  str_trim result "  hello world  "
  echo "trim:   '$result'"
  str_repeat result "ab" 4
  echo "repeat: '$result'"
  words=( one two three )
  str_join result ', ' words
  echo "join:   '$result'"
}

Exercise 3 — Auto-doc extractor

Write a script libdoc.sh LIBFILE that:

  • Parses the ## doc-comment blocks from a library file (as shown in section 4)
  • Prints a clean reference page with each function's signature and description
  • Also accepts --list to print just the function names, one per line
  • Exits 1 if no documented functions are found
#!/usr/bin/env bash
set -euo pipefail

MODE='full'
if [[ "${1:-}" == '--list' ]]; then
  MODE='list'; shift
fi
LIBFILE="${1:?usage: $0 [--list] LIBFILE}"
[[ -f $LIBFILE ]] || { echo "Not found: $LIBFILE" >&2; exit 1; }

count=0

awk -v mode="$MODE" '
BEGIN { doc = ""; in_doc = 0 }
/^##/ {
  sub(/^## ?/, "")
  doc = doc $0 "\n"
  in_doc = 1
  next
}
in_doc && /^[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)/ {
  match($0, /^[a-zA-Z_][a-zA-Z0-9_]*/)
  fname = substr($0, RSTART, RLENGTH)
  if (mode == "list") {
    print fname
  } else {
    print "\033[0;36m" fname "\033[0m"
    printf "%s", doc
    print ""
  }
  count++
  doc = ""; in_doc = 0
  next
}
{ doc = ""; in_doc = 0 }
END { exit (count == 0 ? 1 : 0) }
' "$LIBFILE"

Exercise 4 — Loader with dependency resolution

Extend the loader pattern so libraries can declare their own dependencies. Write lib/loader.sh where each library file may include a line like:

# REQUIRES: utils logging

The loader should parse this line and automatically source the listed libraries first, detecting circular dependencies and aborting with a clear error if one is found.

#!/usr/bin/env bash
# lib/loader.sh — dependency-aware library loader

[[ -n "${_LIB_LOADER_LOADED:-}" ]] && return 0
readonly _LIB_LOADER_LOADED=1

_LIB_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
declare -gA _LIB_LOADING   # currently in-flight (cycle detection)

lib_load() {
  local lib
  for lib in "$@"; do
    local guard="_LIB_${lib^^}_LOADED"
    [[ -n "${!guard:-}" ]] && continue   # already loaded

    if [[ -n "${_LIB_LOADING[$lib]:-}" ]]; then
      printf 'lib_load: circular dependency detected: %s\n' "$lib" >&2
      return 1
    fi

    local path="$_LIB_ROOT/$lib.sh"
    [[ -f $path ]] || { printf 'lib_load: not found: %s\n' "$path" >&2; return 1; }

    # Parse REQUIRES line (e.g.  # REQUIRES: utils logging)
    local req_line
    req_line=$(grep -m1 '^# REQUIRES:' "$path" || true)
    if [[ -n $req_line ]]; then
      local -a deps
      # shellcheck disable=SC2206
      deps=( ${req_line#*: } )
      _LIB_LOADING[$lib]=1
      lib_load "${deps[@]}" || return 1
      unset '_LIB_LOADING[$lib]'
    fi

    # shellcheck source=/dev/null
    _LIB_LOADING[$lib]=1
    source "$path" || return 1
    unset '_LIB_LOADING[$lib]'
  done
}