jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Bash & Shell Scripting · Part 3 — Conditionals: if, test, [[ ]] & Exit Codes

Master bash conditionals: if runs on exit codes (0 = true), test vs [[ ]], file/string/numeric operators, && || short-circuit, and case patterns — bilingual, with exercises.

This is Part 3 of a 10-part series on writing robust shell scripts {Đây là Phần 3 của series 10 bài về viết shell script chắc chắn}. In Part 1 you learned exit codes; in Part 2 you mastered variables and quoting {Ở Phần 1 bạn học exit code; ở Phần 2 bạn nắm biến và quoting}. Now we wire those ideas into branching logic — the if statement and everything that feeds it {Giờ ta nối các ý đó vào logic rẽ nhánh — câu lệnh if và mọi thứ nuôi nó}.


The one rule: if checks exit codes, not booleans {Quy tắc cốt lõi: if kiểm exit code, không phải boolean}

Other languages use true / false {Ngôn ngữ khác dùng true / false}. Bash has no boolean type {Bash không có kiểu boolean}. Instead, every command returns an exit code when it finishes {Thay vào đó, mọi lệnh trả về exit code khi kết thúc}:

  • 0 → success → the condition is true {0 → thành công → điều kiện là đúng}
  • Non-zero → failure → the condition is false {Khác 0 → thất bại → điều kiện là sai}

When you write if some_command; then, bash runs some_command and looks at $? {Khi bạn viết if some_command; then, bash chạy some_command rồi nhìn $?}. If $? is 0, the then block runs; otherwise the else block (if any) runs {Nếu $?0, khối then chạy; không thì khối else (nếu có) chạy}.

#!/usr/bin/env bash

# A command that succeeds → exit 0 → "true"
if ls /tmp > /dev/null 2>&1; then
  echo " /tmp exists and is readable"
else
  echo " /tmp is missing or unreadable"
fi

# A command that fails → non-zero → "false"
if grep -q "ROOT" /etc/shadow 2>/dev/null; then
  echo "found ROOT in shadow"   # unlikely without root
else
  echo "could not read /etc/shadow (expected for normal users)"
fi

The built-in commands true and false exist purely to return 0 and 1 {Lệnh built-in truefalse chỉ để trả 01}:

if true; then echo "always runs"; fi
if false; then echo "never runs"; else echo "always the else"; fi
[[ -f file.txt ]] $? = 0 ? then (true / exists) exit code 0 = success else (false / missing) non-zero = failure
A test like [[ -f file.txt ]] runs as a command; exit code 0 branches to then, non-zero to else

if / elif / else / `fi syntax {Cú pháp if / elif / else / fi}

The full shape is always closed with fi (if spelled backward) {Hình dạng đầy đủ luôn đóng bằng fi (if viết ngược)}:

if command_or_test; then
  # runs when exit code is 0
elif another_command_or_test; then
  # runs when the first failed but this one succeeded
else
  # runs when every preceding test failed
fi

A practical deploy guard {Một kiểm tra deploy thực tế}:

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

ENV="${1:-staging}"

if [[ "$ENV" == "production" ]]; then
  echo "⚠ production deploy — extra checks"
  if ! command -v docker &>/dev/null; then
    echo "docker not found" >&2
    exit 1
  fi
elif [[ "$ENV" == "staging" ]]; then
  echo "staging deploy — lighter checks"
else
  echo "unknown env: $ENV" >&2
  exit 2
fi

command -v docker is a real command whose exit code tells you whether docker is on PATH {command -v docker là lệnh thật, exit code cho biết docker có trên PATH hay không}. The ! in if ! command inverts the exit code {! trong if ! command đảo exit code}: success becomes failure and vice versa {thành công thành thất bại và ngược lại}.


[ ] (test) vs [[ ]] (bash) {[ ] (test) vs [[ ]] (bash)}

Both forms evaluate conditions, but they are not interchangeable {Cả hai đều đánh giá điều kiện, nhưng không thay thế được nhau}.

Feature {Tính năng}[ ] / test[[ ]] (bash only)
Portability {Portable}POSIX — works in shBash only
Word splitting {Tách từ}Yes — risky with unquoted vars {Có — rủi ro khi biến không quote}No — safer with "$var"
&& / || inside testNot in POSIX [ ]Supported natively
Regex =~NoYes — [[ $s =~ ^[0-9]+$ ]]
Filename globsLiteral in [ ]Patterns work in [[ ]]

[ is literally the test command {[ thực chất là lệnh test}. It needs spaces around every token and a closing ]:

# These are equivalent:
test -f report.csv
[ -f report.csv ]

[[ is a bash keyword — not a command — so it handles quoting and operators more safely {[[ là keyword bash — không phải lệnh — nên xử lý quoting và toán tử an toàn hơn}:

file="my data.csv"

# [[ ]] — safe: no word-splitting on spaces
if [[ -f "$file" ]]; then
  echo "found: $file"
fi

# [ ] with an empty variable can break or behave oddly
name=""
# [ $name = "admin" ]   # ← ERROR: missing operand (word-splitting ate the empty token)
if [[ "$name" == "admin" ]]; then
  echo "admin"
fi

Recommendation {Khuyến nghị}: in bash scripts with #!/usr/bin/env bash, prefer [[ ]] for tests {trong script bash có #!/usr/bin/env bash, ưu tiên [[ ]] cho test}. Reserve [ ] when you must stay POSIX-portable {Chỉ dùng [ ] khi bắt buộc portable POSIX}.


Test operators — file, string, numeric {Toán tử test — file, chuỗi, số}

The operators below work inside both [ ] and [[ ]] unless noted {Các toán tử dưới dùng được trong cả [ ][[ ]] trừ khi ghi chú}.

File tests {Test file}

OperatorMeaning {Ý nghĩa}Example
-eexists (any type) {tồn tại (mọi loại)}[[ -e path ]]
-fregular file {file thường}[[ -f config.yml ]]
-ddirectory {thư mục}[[ -d /var/log ]]
-rreadable {đọc được}[[ -r secrets.env ]]
-wwritable {ghi được}[[ -w . ]]
-xexecutable {chạy được}[[ -x deploy.sh ]]
-sexists and not empty {tồn tại và không rỗng}[[ -s error.log ]]
#!/usr/bin/env bash
LOG="app.log"

if [[ ! -e "$LOG" ]]; then
  touch "$LOG"
  echo "created empty $LOG"
elif [[ -s "$LOG" ]]; then
  lines=$(wc -l < "$LOG")
  echo "$LOG has $lines lines"
else
  echo "$LOG exists but is empty"
fi

String tests {Test chuỗi}

OperatorMeaning {Ý nghĩa}Example
-zstring is empty {chuỗi rỗng}[[ -z "$reply" ]]
-nstring is not empty {chuỗi không rỗng}[[ -n "$USER" ]]
= / ==strings equal {chuỗi bằng}[[ "$a" == "$b" ]] (== in [[ ]] only)
!=strings not equal {chuỗi khác}[[ "$branch" != "main" ]]
<lexicographic less {nhỏ hơn theo thứ tự từ điển}[[ "$a" < "$b" ]] (bash [[ ]] only)
read -r -p "Continue? [y/N] " answer
if [[ -z "$answer" || "$answer" =~ ^[Nn] ]]; then
  echo "aborted"
  exit 0
fi
echo "continuing…"

Numeric tests {Test số}

Use -eq, -ne, -lt, -le, -gt, -ge — never = or == for numbers {Dùng -eq, -ne, -lt, -le, -gt, -ge — không dùng = hay == cho số}:

OperatorMeaning {Ý nghĩa}
-eqequal
-nenot equal
-ltless than
-leless or equal
-gtgreater than
-gegreater or equal
count=12

if [[ $count -lt 10 ]]; then
  echo "too few"
elif [[ $count -ge 10 && $count -le 100 ]]; then
  echo "just right"
else
  echo "too many"
fi

Why numeric and string comparison differ {Vì sao so sánh số và chuỗi khác nhau}

[[ "10" < "2" ]] is true because < compares strings lexicographically ("1" comes before "2") {[[ "10" < "2" ]]đúng< so sánh chuỗi theo thứ tự từ điển ("1" đứng trước "2")}. [[ 10 -lt 2 ]] is false because -lt compares integers {[[ 10 -lt 2 ]]sai-lt so sánh số nguyên}. For arithmetic in bash 4+, $(( )) is often clearer {Với số học trong bash 4+, $(( )) thường rõ hơn}:

a=10
b=2
if (( a > b )); then echo "10 is greater than 2"; fi

&& and || — short-circuit chains {&&|| — chuỗi short-circuit}

These operators connect commands, not just tests {Các toán tử này nối lệnh, không chỉ test}:

  • cmd1 && cmd2 — run cmd2 only if cmd1 succeeds (exit 0) {chạy cmd2 chỉ khi cmd1 thành công (exit 0)}
  • cmd1 || cmd2 — run cmd2 only if cmd1 fails (non-zero) {chạy cmd2 chỉ khi cmd1 thất bại (khác 0)}
# Create parent dir only if mkdir for the leaf fails
mkdir -p ./data/cache || { echo "cannot create cache dir" >&2; exit 1; }

# Only run tests when the config file exists
[[ -f Makefile ]] && make test

# Default value pattern (from Part 2)
: "${PORT:=3000}"    # assign 3000 only if PORT was unset or empty

Inside [[ ]], && and || combine tests without nesting extra ifs {Trong [[ ]], &&|| gộp test mà không cần lồng thêm if}:

if [[ -n "$1" && -f "$1" && -r "$1" ]]; then
  echo "processing readable file: $1"
fi

case — pattern matching {case — khớp mẫu}

When you branch on many discrete values, case is cleaner than a ladder of elifs {Khi rẽ nhánh theo nhiều giá trị rời rạc, case gọn hơn chuỗi elif}:

#!/usr/bin/env bash
action="${1:-help}"

case "$action" in
  start|up)
    echo "starting services…"
    ;;
  stop|down)
    echo "stopping services…"
    ;;
  restart)
    echo "restarting…"
    ;;
  help|--help|-h)
    echo "usage: $0 {start|stop|restart}"
    ;;
  *)
    echo "unknown action: $action" >&2
    exit 1
    ;;
esac

Patterns are glob patterns, not regex {Mẫu là glob, không phải regex}: * matches anything, ? matches one character, [abc] matches a character class {* khớp bất kỳ, ? khớp một ký tự, [abc] khớp một lớp ký tự}. Each branch ends with ;;. The *) branch is the default (like else) {Mỗi nhánh kết thúc bằng ;;. Nhánh *) là mặc định (như else)}.

# Match file extensions
filename="report.tar.gz"
case "$filename" in
  *.tar.gz) echo "compressed tarball" ;;
  *.tar)    echo "tar archive" ;;
  *.zip)    echo "zip archive" ;;
  *)        echo "unknown type" ;;
esac

Putting it together: a small health-check script {Gộp lại: script health-check nhỏ}

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

URL="${1:-http://localhost:3000/health}"
TIMEOUT=5

if ! command -v curl &>/dev/null; then
  echo "curl is required" >&2
  exit 1
fi

http_code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$URL" || echo "000")

case "$http_code" in
  200)
    echo "OK $URL$http_code"
    exit 0
    ;;
  000)
    echo "FAIL $URL → timeout or connection error" >&2
    exit 1
    ;;
  *)
    echo "FAIL $URL → HTTP $http_code" >&2
    exit 1
    ;;
esac

Every check here is exit-code driven: command -v, curl, case, and final exit {Mọi kiểm tra ở đây đều dựa exit code: command -v, curl, case, và exit cuối}.


Mistakes beginners make {Lỗi người mới hay mắc}

  • ❌ Using = or == for numbers[ "$a" = "$b" ] compares strings; use -eq for integers {Dùng = hoặc == cho số[ "$a" = "$b" ] so sánh chuỗi; dùng -eq cho số nguyên}. [[ $a = $b ]] with unquoted numbers happens to work but [[ $a -eq $b ]] is explicit {[[ $a = $b ]] với số không quote tình cờ chạy nhưng [[ $a -eq $b ]] rõ ràng hơn}.
  • Missing spaces inside [ ][ -f file] is wrong; [ -f file ] is correct. [ "$x"="y" ] is a syntax error; you need spaces around = {Thiếu khoảng trắng trong [ ][ -f file] sai; [ -f file ] đúng. [ "$x"="y" ] là lỗi cú pháp; cần khoảng trắng quanh =}.
  • Unquoted variables in [ ] — if $name is empty, [ $name = admin ] becomes [ = admin ] and errors. Always "$name" {Biến không quote trong [ ] — nếu $name rỗng, [ $name = admin ] thành [ = admin ] và lỗi. Luôn "$name"}.
  • ❌ Using == for assignment inside [ ][ "$x" == "y" ] is a test (bash extension in [ ]); x==y without spaces is not valid test syntax. Assignment is x=y outside brackets {Dùng == để gán trong [ ][ "$x" == "y" ] là test; x==y không có khoảng trắng không phải cú pháp test hợp lệ. Gán là x=y bên ngoài ngoặc}.
  • ❌ Expecting if [ -f $file ] to work when $file contains spaces — quote it: [[ -f "$file" ]] {Tưởng if [ -f $file ] chạy khi $file có khoảng trắng — phải quote: [[ -f "$file" ]]}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. Write check-port.sh that takes a port number, uses [[ numeric tests ]] to print valid if the port is between 1 and 65535 inclusive, otherwise invalid and exit 1 {Viết check-port.sh nhận số port, dùng test số [[ ]] in valid nếu port từ 1 đến 65535, không thì invalid và exit 1}.
  2. Write a script that accepts a file path and prints one line: missing, empty, or readable (N bytes) using file test operators -e, -s, -r {Viết script nhận đường dẫn file và in một dòng: missing, empty, hoặc readable (N bytes) bằng toán tử -e, -s, -r}.
  3. Rewrite exercise 1’s validation as a case statement that accepts 80, 443, or 8080 as “well-known” and anything else in range as “custom” {Viết lại kiểm tra bài 1 bằng case: 80, 443, 8080 là “well-known”, còn lại trong khoảng là “custom”}.
Solution {Lời giải}
#!/usr/bin/env bash
# check-port.sh
port="${1:-}"

if [[ -z "$port" || ! "$port" =~ ^[0-9]+$ ]]; then
  echo "invalid"
  exit 1
fi

if [[ "$port" -ge 1 && "$port" -le 65535 ]]; then
  echo "valid"
else
  echo "invalid"
  exit 1
fi
#!/usr/bin/env bash
# file-status.sh
path="${1:-}"

if [[ ! -e "$path" ]]; then
  echo "missing"
elif [[ ! -s "$path" ]]; then
  echo "empty"
elif [[ -r "$path" ]]; then
  bytes=$(wc -c < "$path" | tr -d ' ')
  echo "readable ($bytes bytes)"
else
  echo "missing"   # exists but not readable — treat as inaccessible
  exit 1
fi
#!/usr/bin/env bash
# port-classify.sh
port="${1:-}"

if [[ -z "$port" || ! "$port" =~ ^[0-9]+$ || "$port" -lt 1 || "$port" -gt 65535 ]]; then
  echo "invalid"
  exit 1
fi

case "$port" in
  80|443|8080)
    echo "well-known"
    ;;
  *)
    echo "custom"
    ;;
esac

Exercise 1 uses [[ numeric -ge / -le ]] after validating the input is digits {Bài 1 dùng -ge / -le số trong [[ ]] sau khi xác nhận input là chữ số}. Exercise 2 chains -e, -s, and -r in order {Bài 2 xâu -e, -s, và -r theo thứ tự}. Exercise 3 shows case for discrete “well-known” ports with a * fallback {Bài 3 dùng case cho port “well-known” rời rạc với nhánh * mặc định}.


Takeaway {Điều cốt lõi}

if does not ask “is this true?” — it asks “did the last command succeed?” {if không hỏi “cái này đúng không?” — nó hỏi “lệnh vừa rồi thành công chưa?”}. Master [[ ]] for bash tests, pick the right operator (file -f, string ==, numeric -eq), and reach for case when you match patterns {Nắm [[ ]] cho test bash, chọn đúng toán tử (file -f, chuỗi ==, số -eq), và dùng case khi khớp mẫu}. Short-circuit with && / || to keep scripts flat and readable {Short-circuit bằng && / || để script phẳng và dễ đọc}.