Tcl · Module 14
Validating Input and Failing Loudly
Tcl will happily compare a number to an empty string and tell you nothing is wrong. Validation at the boundary is what converts that silent wrong answer into an error you can act on.
Foundation19 min readtclvalidationstring-isfile-existsdefensiveerror-handling
Part I · Module 14 · Chapter 14.11 · Validating Input and Failing Loudly
This module has circled one fact repeatedly: Tcl does not object to a value being the wrong kind of thing. Comparison operators fall back to string comparison rather than raising (14.7 §7). An empty string is a perfectly good operand. A missing key is an error, but a meaningless value is not.
The consequence is that a Tcl script's worst failure mode is not a crash. It is finishing successfully with a wrong answer. Validation is how you close that gap, and it costs about four procedures.
1. string is — and the -strict flag you must not forget
string is is the type test, and its default behaviour has one trap that matters more than everything else in this chapter:
puts [format " %-12s %-8s %-8s %-8s %-8s" value int int-s double double-s]
foreach v [list 42 -3 0 3.14 "" " " "abc" "1e3" "0x1F" "007" "1.0e-3" "+5" "1 2"] {
puts [format " %-12s %-8s %-8s %-8s %-8s" "'$v'" \
[string is integer $v] [string is integer -strict $v] \
[string is double $v] [string is double -strict $v]]
} value int int-s double double-s
'42' 1 1 1 1
'-3' 1 1 1 1
'0' 1 1 1 1
'3.14' 0 0 1 1
'' 1 0 1 0
' ' 0 0 0 0
'abc' 0 0 0 0
'1e3' 0 0 1 1
'0x1F' 1 1 1 1
'007' 1 1 1 1
'1.0e-3' 0 0 1 1
'+5' 1 1 1 1
'1 2' 0 0 0 0Four more rows worth reading:
' '(one space) is 0 either way. Only the empty string is special-cased, not whitespace. So a value of" "is correctly rejected even without-strict— which is exactly why the''behaviour catches people by surprise.'0x1F'is a valid integer. Tcl accepts hex, so a check that passes does not mean the value is decimal.'007'is a valid integer — andexpr {007 + 1}is 8 on this interpreter, not an octal surprise.'1e3'is a double but not an integer. If you need a whole number,string is integer -strictis the test;string is double -strictadmits exponent notation.
The useful members of the family for validation work: integer, double, boolean, alpha, alnum, digit, space, list. string is list is worth knowing — it tests whether a value is a well-formed list, which catches an unbalanced brace in data read from a file.
2. File checks are not interchangeable
There are four file predicates you will reach for, and they answer four different questions:
puts [format " %-12s %-7s %-7s %-7s %-9s %-7s" target exists isfile isdir readable size]
foreach t {good.txt d missing.txt noread.txt} {
puts [format " %-12s %-7s %-7s %-7s %-9s %-7s" $t \
[file exists $t] [file isfile $t] [file isdirectory $t] \
[file readable $t] [expr {[file exists $t] ? [file size $t] : "-"}]]
} target exists isfile isdir readable size
good.txt 1 1 0 1 5
d 1 0 1 1 64
missing.txt 0 0 0 0 -
noread.txt 1 1 0 0 2A directory exists and is readable and has a size, and is not a file. A chmod 000 file exists and is a file and is not readable. file exists alone distinguishes none of this.
3. The directory-open trap
This is why the distinction is not pedantic. file exists passes for a directory, so the obvious guard lets one through — and open succeeds:
set rc [catch {open d r} fp]
puts " open d r -> rc=$rc, handle '$fp'"
if {$rc == 0} {
set rc2 [catch {read $fp} data]
puts " read \$fp -> rc=$rc2"
if {$rc2} { puts " error: $data" } else { puts " got [string length $data] bytes" }
catch {close $fp}
} open d r -> rc=0, handle 'file5'
read $fp -> rc=1
error: error reading "file5": illegal operation on a directoryopen returned a real channel handle. The failure arrived one step later, at read, with a message — illegal operation on a directory — that points at a channel number rather than at the path you were given. In a loop over a hundred paths, that diagnosis costs real time.
file isfile is the correct gate, and it rejects both the directory and the missing path:
foreach t {good.txt d missing.txt} {
if {![file isfile $t]} {
puts " $t -> rejected: not a readable regular file"
continue
}
set fp [open $t r]
puts " $t -> read [string length [read $fp]] bytes"
close $fp
} good.txt -> read 5 bytes
d -> rejected: not a readable regular file
missing.txt -> rejected: not a readable regular file4. Empty: three tests that disagree
"Is it empty" has three plausible spellings and they are not the same test:
foreach v [list {} {{}} {a} " " "0"] {
puts [format " %-8s llength=%-3s \$v eq \"\"=%-3s string length=%-3s" \
"'$v'" [llength $v] [expr {$v eq ""}] [string length $v]]
} '' llength=0 $v eq ""=1 string length=0
'{}' llength=1 $v eq ""=0 string length=2
'a' llength=1 $v eq ""=0 string length=1
' ' llength=0 $v eq ""=0 string length=2
'0' llength=1 $v eq ""=0 string length=1'{}'is a one-element list whose single element is empty.llengthsays 1 — correctly.string lengthsays 2, because the braces are characters.' '— two spaces — hasllength0 butstring length2 and is noteq "". A line of whitespace from a file is an empty list and a non-empty string.'0'is non-empty by every test. Worth stating because0is falsy in a condition, soif {$v}is not an emptiness test.
The rule: use llength $x == 0 for "is this list empty", and $x eq "" for "is this string empty". Pick according to what the value is, and never use if {$x}.
5. Four checks worth writing once
Validation is only used if it is short to call. A small vocabulary covers most of what you need:
proc need_readable_file {path what} {
if {![file exists $path]} { error "$what does not exist: $path" {} [list INPUT MISSING $path] }
if {![file isfile $path]} { error "$what is not a regular file: $path" {} [list INPUT NOTFILE $path] }
if {![file readable $path]} { error "$what is not readable: $path" {} [list INPUT DENIED $path] }
return $path
}
proc need_number {value what} {
if {![string is double -strict $value]} {
error "$what must be a number, got '$value'" {} [list INPUT NOTNUM $value]
}
return $value
}
proc need_nonempty_list {lst what} {
if {[llength $lst] == 0} { error "$what is empty" {} [list INPUT EMPTY] }
return $lst
}
proc need_in {value allowed what} {
if {[lsearch -exact $allowed $value] < 0} {
error "$what must be one of [join $allowed {, }], got '$value'" {} [list INPUT RANGE $value]
}
return $value
}Every one of the eleven cases, passing and failing:
file ok OK good.txt
file missing REJECT the netlist does not exist: missing.txt
code INPUT MISSING missing.txt
file is a dir REJECT the netlist is not a regular file: d
code INPUT NOTFILE d
file no read REJECT the netlist is not readable: noread.txt
code INPUT DENIED noread.txt
number ok OK 1.25
number empty REJECT the period must be a number, got ''
code INPUT NOTNUM {}
number text REJECT the period must be a number, got 'abc'
code INPUT NOTNUM abc
list ok OK a b
list empty REJECT the file list is empty
code INPUT EMPTY
mode ok OK setup
mode wrong REJECT the analysis mode must be one of setup, hold, got 'both'
code INPUT RANGE bothFour design decisions in those twenty lines:
- Each takes a
whatdescribing the role —"the netlist","the period". The message then names the thing in the user's terms, not the variable's.the limit must be a number, got ''is actionable;bad valueis not. - Each returns the value on success, so a check can wrap an assignment:
set limit [need_number $limit "the limit"]. - Each quotes the offending value —
got ''tells you it was empty, whichgotalone would not. - Each carries a structured
-errorcode, so a caller can distinguishINPUT MISSINGfromINPUT NOTNUMwith 14.5 §7's dispatch instead of matching on message text.
6. Where to put the checks
At the boundary, meaning the points where data enters your control:
- a procedure's parameters, if it is part of your public surface,
- values read from a file, line by line,
$::env(...)and command-line arguments,- anything a tool handed you.
Not on every internal call. A procedure that is only ever called by three of your own procedures, all of which validated already, does not need to re-check — that is noise, and it trains readers to skip the checks that matter. Validate once, where the value crosses in.
The one exception worth making: a value entering a loop body from data is a boundary even if the loop is internal, because one bad line in a ten-thousand-line file is the case you are actually defending against.
7. Fail loudly, and collect when you can
Two shapes, for two situations.
Stop at the first problem when continuing is meaningless — a missing input file, an unparseable configuration. error is right, and the message should name the value and the expectation.
Collect every problem when the user will have to fix several things and would rather hear them all at once — a preflight, a batch of files, a form of parameters. 14.10 §8 builds exactly this: accumulate into a list, report the count, print each.
The deciding question is whether the next check's result is still meaningful after this one failed. For independent checks, collect. For dependent ones, stop.
8. The whole argument in one example
Here is a procedure that averages slack from a report and counts how many paths are below a limit. Two versions, differing only in two lines of validation.
The unvalidated one:
proc margin_unchecked {path limit} {
set fp [open $path r]
set n 0 ; set total 0.0
while {[gets $fp line] >= 0} {
if {$line eq ""} { continue }
lassign $line pin slack
if {$slack < $limit} { incr n }
set total [expr {$total + $slack}]
}
close $fp
return "$n below $limit, mean [format %.3f [expr {$total / 3}]]"
}The validated one:
proc margin_checked {path limit} {
need_readable_file $path "the slack report"
need_number $limit "the limit"
set fp [open $path r]
set n 0 ; set total 0.0 ; set rows 0
while {[gets $fp line] >= 0} {
if {$line eq ""} { continue }
if {[llength $line] != 2} { error "malformed line: '$line'" {} [list DATA SHAPE $line] }
lassign $line pin slack
need_number $slack "slack for $pin"
if {$slack < $limit} { incr n }
set total [expr {$total + $slack}]
incr rows
}
close $fp
return "$n below $limit, mean [format %.3f [expr {$total / $rows}]]"
}Now call both with a limit that came from an environment variable nobody set — an empty string:
=== a bad limit, unvalidated ===
0 / 0 below , mean 1.343
=== the same bad limit, validated ===
1 / the limit must be a number, got ''
=== a good run, validated ===
0 / 1 below 1.0, mean 1.343Three further differences worth noticing between the two versions:
expr {$total / 3}versusexpr {$total / $rows}. The unchecked version hard-codes the row count, so it silently reports a wrong mean the first time the report has four rows. Counting what you actually processed is itself a form of validation.llength $line != 2rejects a malformed line with the line in the message. Without it,lassignsets the missing field to the empty string (Chapter 6.14) and the empty string flows onward as a number — the same silent failure one level down.need_number $slack "slack for $pin"names the pin, so the message points at the row in a ten-thousand-line file.
That is the whole thesis of the chapter and of the module: the cost of validation is a few lines; the cost of its absence is a result you trust and should not.
9. Exercises
Exercise 1. What does string is integer "" return, and why is that the most important fact in this chapter?
Exercise 2. file exists $p passed and open $p r succeeded, then read failed. What was $p, and which check was missing?
Exercise 3. Which test for "is this list empty", and why not $x eq ""?
Exercise 4. Why does each need_* procedure take a what argument and return its value?
Exercise 5. error "bad: $v" {} {INPUT NOTNUM $v} produces an error code containing a literal $v. Why, and what is the fix?
Exercise 6. When should validation collect all problems rather than stopping at the first?
Exercise 7. The unvalidated margin_unchecked returned successfully with 0 below. Explain exactly how an empty limit produced that.
Where this fits
Part of the Tcl curriculum.
