Named Arguments in a Bash Script

Let's face it, positional arguments such as $1 and $2 are neither descriptive nor flexible. There is a better way to supply arguments: with this simple trick you'll get --named arguments --in your-script, which is waaaay better 🤓.

Parsing script arguments

The following script declares its argument variables and lists their names in a readonly whitelist. Before assigning a value, is_allowed_argument checks that the name is both whitelisted and declared:

declare service_name="" tag="" namespace="" env=""
declare -ar allowed_arguments=(service_name tag namespace env)

is_allowed_argument() {
    local candidate=$1
    local allowed

    for allowed in "${allowed_arguments[@]}"; do
        if [[ $candidate == "$allowed" ]]; then
            declare -p -- "$candidate" &>/dev/null
            return
        fi
    done

    return 1
}

die() {
    printf "Script failed: %s\n" "$1" >&2
    exit 1
}

while [ $# -gt 0 ]; do
    if [[ $1 == "--"* ]]; then
        if ! is_allowed_argument "${1#--}"; then
            die "Unknown parameter: $1"
        fi

        if (( $# < 2 )); then
            die "Missing value for $1"
        fi

        if [[ $2 == "--"* ]]; then
            die "Missing value for $1"
        fi

        printf -v "${1#--}" "%s" "$2"
        shift 2
    else
        die "Unexpected positional argument: $1"
    fi
done

echo "service_name: '$service_name' env: '$env'"

When we execute the script with ./test.sh --service_name catalog --env test, it returns:

$ ./test.sh --service_name catalog --env test
service_name: 'catalog' env: 'test'

The parser accepts only --name value arguments whose names are declared and included in allowed_arguments. It rejects unknown options, missing values, and positional arguments. Values cannot start with --, because the parser treats them as another option. If you want to support flags without values, check Support for --help?

Argument validation

First, let's define a usage function to show how our script works:

function usage {
    echo ""
    echo "Deploys an ECR image to Atlas using GitOps and ArgoCD."
    echo ""
    echo "usage: $0 --service_name string --tag string --namespace string --env string "
    echo ""
    echo "  --service_name string   name of the service"
    echo "                          (example: blaze-search-term-redirect-service)"
    echo "  --tag string            tag of the image to deploy"
    echo "                          (example: 804-a325d6a)"
    echo "  --namespace string      namespace of the cluster"
    echo "                          (example: eos)"
    echo "  --env string            env to which to deploy the tag"
    echo "                          (example: dev)"
    echo ""
}

Now, let's check if the parameters are supplied. If one of the parameters is missing, display usage and terminate the script. We use the -z conditional expression to check if the variable is empty:

if [[ -z $service_name ]]; then
    usage
    die "Missing parameter --service_name"
elif [[ -z $tag ]]; then
    usage
    die "Missing parameter --tag"
elif [[ -z $namespace ]]; then
    usage
    die "Missing parameter --namespace"
elif [[ -z $env ]]; then
    usage
    die "Missing parameter --env"
fi

Support for --help?

The easiest way to add --help is to handle it in the loop that turns arguments into variables:

while [ $# -gt 0 ]; do
    if [[ $1 == "--help" ]]; then
        usage
        exit 0
    elif [[ $1 == "--"* ]]; then
        if ! is_allowed_argument "${1#--}"; then
            die "Unknown parameter: $1"
        fi

        if (( $# < 2 )); then
            die "Missing value for $1"
        fi

        if [[ $2 == "--"* ]]; then
            die "Missing value for $1"
        fi

        printf -v "${1#--}" "%s" "$2"
        shift 2
    else
        die "Unexpected positional argument: $1"
    fi
done

Any flag that does not take a value can be added to this loop.

Write better scripts: use ShellCheck

The article Please stop writing shell scripts makes some excellent points on why writing a Bash script is hard for programmers: bash behavior is most likely not like the programming language you're familiar with. The ShellCheck extension for Visual Studio Code will help you catch some of the pitfalls. ShellCheck reports warnings with codes such as SC2086; its wiki explains what each code means:

Show the explanation of error code SC2086.
Show the explanation of SC2086.

Complete example

Putting everything together gives us the following complete script:

#!/usr/bin/env bash
set -euo pipefail

declare service_name="" tag="" namespace="" env=""
declare -ar allowed_arguments=(service_name tag namespace env)

is_allowed_argument() {
    local candidate=$1
    local allowed

    for allowed in "${allowed_arguments[@]}"; do
        if [[ $candidate == "$allowed" ]]; then
            declare -p -- "$candidate" &>/dev/null
            return
        fi
    done

    return 1
}

usage() {
    echo ""
    echo "Deploys an ECR image to Atlas using GitOps and ArgoCD."
    echo ""
    echo "usage: $0 --service_name string --tag string --namespace string --env string"
    echo ""
    echo "  --service_name string   name of the service"
    echo "                          (example: blaze-search-term-redirect-service)"
    echo "  --tag string            tag of the image to deploy"
    echo "                          (example: 804-a325d6a)"
    echo "  --namespace string      namespace of the cluster"
    echo "                          (example: eos)"
    echo "  --env string            env to which to deploy the tag"
    echo "                          (example: dev)"
    echo "  --help                  show this help"
    echo ""
}

die() {
    printf "Script failed: %s\n" "$1" >&2
    exit 1
}

while [ $# -gt 0 ]; do
    if [[ $1 == "--help" ]]; then
        usage
        exit 0
    elif [[ $1 == "--"* ]]; then
        if ! is_allowed_argument "${1#--}"; then
            die "Unknown parameter: $1"
        fi

        if (( $# < 2 )); then
            die "Missing value for $1"
        fi

        if [[ $2 == "--"* ]]; then
            die "Missing value for $1"
        fi

        printf -v "${1#--}" "%s" "$2"
        shift 2
    else
        die "Unexpected positional argument: $1"
    fi
done

if [[ -z $service_name ]]; then
    usage
    die "Missing parameter --service_name"
elif [[ -z $tag ]]; then
    usage
    die "Missing parameter --tag"
elif [[ -z $namespace ]]; then
    usage
    die "Missing parameter --namespace"
elif [[ -z $env ]]; then
    usage
    die "Missing parameter --env"
fi

printf "Deploying %s:%s to %s/%s\n" \
    "$service_name" "$tag" "$env" "$namespace"

Conclusion

Adding named arguments to a bash script is pretty easy. Declaring the argument variables and listing their names in an explicit whitelist prevents mistyped options and unrelated shell variables from being changed.

Changelog

  • Restricted named arguments to declared variables in an explicit allowlist, added argument validation and a strict-mode complete example, clarified parser limitations, and corrected references.
  • Moved the reusable lib.sh example to Bash script with a lib for named parameters.
  • Shortened parameter validation using elif.
  • Initial article.
expand_less brightness_auto