Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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        0

Four 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 — and expr {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 -strict is the test; string is double -strict admits 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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] : "-"}]]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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         2

A 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  open d r        -> rc=0, handle 'file5'
  read $fp        -> rc=1
    error: error reading "file5": illegal operation on a directory

open 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    good.txt  -> read 5 bytes
    d  -> rejected: not a readable regular file
    missing.txt  -> rejected: not a readable regular file

4. Empty: three tests that disagree

"Is it empty" has three plausible spellings and they are not the same test:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  ''       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. llength says 1 — correctly. string length says 2, because the braces are characters.
  • ' ' — two spaces — has llength 0 but string length 2 and is not eq "". 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 because 0 is falsy in a condition, so if {$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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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 both

Four design decisions in those twenty lines:

  • Each takes a what describing 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 value is 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, which got alone would not.
  • Each carries a structured -errorcode, so a caller can distinguish INPUT MISSING from INPUT NOTNUM with 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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.343

Three further differences worth noticing between the two versions:

  • expr {$total / 3} versus expr {$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 != 2 rejects a malformed line with the line in the message. Without it, lassign sets 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.