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 $? là 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 true và false chỉ để trả 0 và 1}:
if true; then echo "always runs"; fi
if false; then echo "never runs"; else echo "always the else"; fi
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 sh | Bash 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 test | Not in POSIX [ ] | Supported natively |
Regex =~ | No | Yes — [[ $s =~ ^[0-9]+$ ]] |
| Filename globs | Literal 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ả [ ] và [[ ]] trừ khi ghi chú}.
File tests {Test file}
| Operator | Meaning {Ý nghĩa} | Example |
|---|---|---|
-e | exists (any type) {tồn tại (mọi loại)} | [[ -e path ]] |
-f | regular file {file thường} | [[ -f config.yml ]] |
-d | directory {thư mục} | [[ -d /var/log ]] |
-r | readable {đọc được} | [[ -r secrets.env ]] |
-w | writable {ghi được} | [[ -w . ]] |
-x | executable {chạy được} | [[ -x deploy.sh ]] |
-s | exists 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}
| Operator | Meaning {Ý nghĩa} | Example |
|---|---|---|
-z | string is empty {chuỗi rỗng} | [[ -z "$reply" ]] |
-n | string 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ố}:
| Operator | Meaning {Ý nghĩa} |
|---|---|
-eq | equal |
-ne | not equal |
-lt | less than |
-le | less or equal |
-gt | greater than |
-ge | greater 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" ]] là đúng vì < 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 ]] là sai vì -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 {&& và || — 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— runcmd2only ifcmd1succeeds (exit0) {chạycmd2chỉ khicmd1thành công (exit0)}cmd1 || cmd2— runcmd2only ifcmd1fails (non-zero) {chạycmd2chỉ khicmd1thấ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 [[ ]], && và || 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-eqfor integers {Dùng=hoặc==cho số —[ "$a" = "$b" ]so sánh chuỗi; dùng-eqcho 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$nameis empty,[ $name = admin ]becomes[ = admin ]and errors. Always"$name"{Biến không quote trong[ ]— nếu$namerỗ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==ywithout spaces is not valid test syntax. Assignment isx=youtside brackets {Dùng==để gán trong[ ]—[ "$x" == "y" ]là test;x==ykhông có khoảng trắng không phải cú pháp test hợp lệ. Gán làx=ybên ngoài ngoặc}. - ❌ Expecting
if [ -f $file ]to work when$filecontains spaces — quote it:[[ -f "$file" ]]{Tưởngif [ -f $file ]chạy khi$filecó 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}.
- Write
check-port.shthat takes a port number, uses[[numeric tests]]to printvalidif the port is between 1 and 65535 inclusive, otherwiseinvalidand exit1{Viếtcheck-port.shnhận số port, dùng test số[[]]invalidnếu port từ 1 đến 65535, không thìinvalidvà exit1}. - Write a script that accepts a file path and prints one line:
missing,empty, orreadable (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ặcreadable (N bytes)bằng toán tử-e,-s,-r}. - Rewrite exercise 1’s validation as a
casestatement that accepts80,443, or8080as “well-known” and anything else in range as “custom” {Viết lại kiểm tra bài 1 bằngcase:80,443,8080là “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"
;;
esacExercise 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}.