Linux Basic — Custom Error Functions

4 minute read

Custom Error Functions

Three or four small helper functions transform Bash scripts from “stack of commands” to “production tool.” This page shows the patterns that show up in every well-engineered shell script.


The Core Trio — die, warn, info

die()  { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
warn() { printf 'WARN:  %s\n' "$*" >&2; }
info() { printf 'INFO:  %s\n' "$*"; }

Three rules they encode:

  1. Errors and warnings go to stderr (>&2) — so they’re visible even when stdout is piped
  2. die exits non-zero — script aborts
  3. Use printf not echo for predictable handling of %, -e, etc.

Use:

[[ -r "$config" ]] || die "Cannot read $config"
[[ -d "$out_dir" ]] || warn "$out_dir doesn't exist yet"
info "Processed $count items"

die With an Exit Code

die() {
    local rc=1
    if [[ "$1" =~ ^[0-9]+$ ]]; then
        rc=$1; shift
    fi
    printf 'ERROR: %s\n' "$*" >&2
    exit "$rc"
}

die 2 "Invalid arguments"
die "Generic failure"

Now die 2 "…" exits with code 2 (misuse convention); die "…" defaults to 1.


require_cmd — Validate Dependencies

require_cmd() {
    for cmd in "$@"; do
        command -v "$cmd" >/dev/null 2>&1 \
            || die "missing required command: $cmd"
    done
}

require_cmd jq curl awk

Always check at the top of the script — fail fast, before any side effects.

Don’t use which — it’s not POSIX and has flaky exit codes on some systems. command -v is the right tool.


require_var — Validate Environment

require_var() {
    for v in "$@"; do
        [[ -n "${!v:-}" ]] || die "missing required env var: $v"
    done
}

require_var API_TOKEN REGION ENV

${!v} is indirect expansion — v holds the name, ${!v} gives the value. The :- keeps set -u happy.

Or with the parameter-expansion shortcut:

: "${API_TOKEN:?API_TOKEN required}"
: "${REGION:?REGION required}"

The require_var function is friendlier when you want to validate several at once and print a consolidated list.


Log Levels — info, debug, warn, error

LOG_LEVEL=${LOG_LEVEL:-info}

_log() {
    local level=$1; shift
    local levels=(error warn info debug)
    local current
    for ((i=0; i<${#levels[@]}; i++)); do
        [[ "${levels[$i]}" == "$LOG_LEVEL" ]] && current=$i
    done
    local this
    for ((i=0; i<${#levels[@]}; i++)); do
        [[ "${levels[$i]}" == "$level" ]] && this=$i
    done
    (( this <= current )) || return 0

    printf '[%s] %-5s %s\n' "$(date +%H:%M:%S)" "${level^^}" "$*" >&2
}

error() { _log error "$@"; }
warn()  { _log warn  "$@"; }
info()  { _log info  "$@"; }
debug() { _log debug "$@"; }

Set LOG_LEVEL=debug ./myscript to see everything; default info shows the important bits.

A simpler binary form for “verbose mode”:

verbose=${VERBOSE:-0}
debug() { (( verbose )) || return 0; printf 'DEBUG: %s\n' "$*" >&2; }

Colorized Output

Make errors visually obvious — but only when stderr is a real terminal:

if [[ -t 2 ]]; then
    RED=$'\e[31m'
    YELLOW=$'\e[33m'
    BLUE=$'\e[34m'
    BOLD=$'\e[1m'
    RESET=$'\e[0m'
else
    RED='' YELLOW='' BLUE='' BOLD='' RESET=''
fi

die()  { printf '%sERROR:%s %s\n' "$RED$BOLD"    "$RESET" "$*" >&2; exit 1; }
warn() { printf '%sWARN:%s  %s\n' "$YELLOW$BOLD" "$RESET" "$*" >&2; }
info() { printf '%sINFO:%s  %s\n' "$BLUE"        "$RESET" "$*"; }

[[ -t 2 ]] returns true if file descriptor 2 (stderr) is connected to a terminal. When redirected to a file or piped, colors are disabled — no escape-code noise in your log files.

NO_COLOR is becoming a standard environment variable; honour it:

if [[ -z "${NO_COLOR:-}" && -t 2 ]]; then
    RED=$'\e[31m'
    …
fi

Error Context — die_at

Sometimes you want the error message to include where it came from:

die_at() {
    local rc=1
    if [[ "$1" =~ ^[0-9]+$ ]]; then rc=$1; shift; fi
    printf 'ERROR at %s:%d: %s\n' "${BASH_SOURCE[1]}" "${BASH_LINENO[0]}" "$*" >&2
    exit "$rc"
}

# Use:
[[ -r config.txt ]] || die_at "config.txt unreadable"
# ERROR at /path/to/script.sh:42: config.txt unreadable

BASH_SOURCE[1] and BASH_LINENO[0] give you the caller’s file and line.


Putting It All Together — A Library File

Save these helpers in lib.sh:

# lib.sh — common helpers
# Source with:  . "$(dirname "$0")/lib.sh"

die()  { printf 'ERROR: %s\n' "$*" >&2; exit "${EXIT_CODE:-1}"; }
warn() { printf 'WARN:  %s\n' "$*" >&2; }
info() { printf 'INFO:  %s\n' "$*"; }

require_cmd() {
    local cmd
    for cmd in "$@"; do
        command -v "$cmd" >/dev/null || die "missing command: $cmd"
    done
}

require_var() {
    local v
    for v in "$@"; do
        [[ -n "${!v:-}" ]] || die "missing env var: $v"
    done
}

run() {
    info "+ $*"
    "$@"
}

In every script:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

. "$(dirname "$0")/lib.sh"

require_cmd jq curl
require_var API_TOKEN

main() {
    info "Starting"
    run curl -sS --fail "$URL" | jq .data
    info "Done"
}

main "$@"

That four-line header (strict mode + library + dep checks) makes any Bash script look professional and behave predictably.


Common Mistakes

❌ Wrong ✅ Right Issue
echo "ERROR…" (to stdout) echo "ERROR…" >&2 (or printf) errors get piped along with data
echo "%s value" accidentally printf '%s\n' "$msg" echo has weird behaviour with -e/-n
which jq for “is jq installed?” command -v jq which isn’t POSIX
Setting LOG_LEVEL but ignoring it actually check it before printing unused configs are confusing
Coloring output that’s being piped to a log file check [[ -t 2 ]] first escape codes pollute logs

Next → Practice Exercises

Updated: