Tcl Part I · Module 7 · Dictionaries and Arrays
dict exists: Asking Before Reading
The last chapter ended on an error: asking a dictionary for a key it does not have stops the script. Often that is exactly right. Often it is not, and what you actually wanted was a sensible default. This chapter gives you the test that distinguishes those cases, the idiom for each, and a clear argument for why reaching automatically for a default is the wrong habit. It also shows where the obvious check is not as safe as it looks, because dict exists protects you from a missing key and not from a malformed one.
Foundation11 min readTclPart IDictionariesdict existsDefaults
Part I · Module 7 · Chapter 7.3 · dict exists: Asking Before Reading
1. The command
set cfg [dict create mode fast runs 3]
puts [dict exists $cfg mode]
puts [dict exists $cfg timeout]1
0It returns 1 or 0, and it takes a value — $cfg, not cfg — because it only reads. That is the rule from Chapter 7.2 §7.
2. Filling in a default
The most common use is to supply a value for a key the caller did not set:
set cfg [dict create mode fast]
if {![dict exists $cfg runs]} {
dict set cfg runs 1
}
puts $cfgmode fast runs 1Note that dict set here takes cfg — bare — because it is modifying. The dict exists on the line above takes $cfg. Both appear in three lines of code, which is a good reason to be deliberate about it.
If you only want the value and do not want to change the dictionary, use the conditional operator from Chapter 4.7:
set cfg [dict create mode fast]
set runs [expr {[dict exists $cfg runs] ? [dict get $cfg runs] : 1}]
puts "runs = $runs"runs = 13. A small helper is usually better
Written more than twice, that pattern wants a name:
proc dget {d key {default ""}} {
if {[dict exists $d $key]} {
return [dict get $d $key]
}
return $default
}
set cfg [dict create mode fast]
puts [dget $cfg mode slow]
puts [dget $cfg runs 1]
puts "[dget $cfg nothing]<-"fast
1
<-Four lines, and every read site gets shorter and says what it means.
4. The alternative: catch
Chapter 1.9 introduced errors; catch lets you run something that might fail and inspect the result instead of stopping:
set cfg [dict create mode fast]
if {[catch {dict get $cfg runs} value]} {
set value 1
}
puts "runs = $value"runs = 1This works. Prefer dict exists anyway. catch traps any failure, so a genuine mistake — a misspelled subcommand, a malformed dictionary — comes back looking exactly like an absent key, and you silently substitute a default for it. dict exists asks the narrow question you actually meant.
5. Empty is not missing
A trap worth seeing once:
set cfg [dict create blank "" mode fast]
puts "exists blank : [dict exists $cfg blank]"
puts "get blank : '[dict get $cfg blank]'"
puts "exists gone : [dict exists $cfg gone]"exists blank : 1
get blank : ''
exists gone : 0The key blank exists and its value is the empty string. So this test is wrong:
# WRONG — cannot distinguish "absent" from "present but empty"
if {[dget $cfg blank] eq ""} {
puts "treating blank as unset"
}treating blank as unsetIf the difference matters — and in configuration handling it usually does, because "the user explicitly set this to nothing" is not the same as "the user said nothing" — test with dict exists and never by comparing the value.
6. Where dict exists is not as safe as it looks
dict exists never complains about a key that is simply absent. It will still raise an error if the dictionary, or something on the path through it, is not a valid dictionary.
set d [dict create a 1 b 2]
puts [dict exists $d zz]0Safe. Now ask for a two-level path:
puts [dict exists $d a b]missing value to go with keyThat is an error, not a 0. Asking for a b tells Tcl to look up a, then treat its value as a dictionary and look up b inside it. The value of a is 1 — a single word, an odd number, and therefore not a dictionary at all. The same happens if the outer value is malformed:
puts [dict exists "a 1 b" a]missing value to go with keyDeep paths are safe when the structure is actually nested:
set c [dict create stages [dict create parse [dict create timeout 60]]]
puts [dict exists $c stages parse timeout]
puts [dict exists $c stages nope timeout]1
0The second one returns 0 rather than erroring, because stages really is a dictionary — nope is just not a key in it. Chapter 7.6 covers this properly.
7. When a default is the wrong answer
Everything so far shows how to avoid the error. The harder question is when you should not.
A default is right when the key is genuinely optional and there is an obvious, safe value to assume:
set verbose [dget $opts verbose 0]
set retries [dget $opts retries 1]Nobody is harmed by assuming quiet output and one attempt.
A default is wrong when the key's absence means something has gone wrong upstream:
# BAD — silently analyses nothing when the field is missing
set target [dget $job target ""]
run_on $targetIf target is missing, the job description is broken. Substituting an empty string turns a clear failure into a run that does nothing, or does something to the wrong thing, and reports success. Here you want the error:
# GOOD — a missing target stops the script, loudly
set target [dict get $job target]Or, if you want a message a human can act on:
if {![dict exists $job target]} {
error "job description has no 'target' field"
}
set target [dict get $job target]job description has no 'target' field8. A worked example
A settings merger that fills in defaults, validates what must be present, and reports clearly:
proc dget {d key {default ""}} {
if {[dict exists $d $key]} { return [dict get $d $key] }
return $default
}
proc normalise {job} {
foreach required {name target} {
if {![dict exists $job $required]} {
error "job is missing required field '$required'"
}
}
dict set job retries [dget $job retries 1]
dict set job verbose [dget $job verbose 0]
return $job
}
set good [dict create name smoke target top]
puts [normalise $good]
set bad [dict create name smoke]
if {[catch {normalise $bad} msg]} {
puts "rejected: $msg"
}name smoke target top retries 1 verbose 0
rejected: job is missing required field 'target'Two kinds of missing key, handled two different ways on purpose. That is the chapter.
9. Exercises
Exercise 1. What do these print?
set d [dict create a 1 b ""]
puts [dict exists $d b]
puts [dict exists $d c]Exercise 2. Rewrite this using dict exists and say what it gains:
if {[catch {set w [dict get $p width]}]} { set w 1 }Exercise 3. Explain why dict exists $d a b can error even though dict exists $d zz cannot, given set d [dict create a 1].
Exercise 4. For each, say default or error and why: (a) a missing color for a report; (b) a missing output_file for a script that writes one; (c) a missing timeout where 60 is the documented default; (d) a missing password for a login.
Exercise 5. Write require — a procedure taking a dictionary and a list of key names that raises a single error naming every missing key at once, rather than stopping at the first.
Exercise 6. Why is [dget $cfg x] eq "" not a correct test for "the key x is absent"? Give a dictionary that proves it.
Answers
1. 1 then 0. The key b exists; its value just happens to be empty.
2.
if {[dict exists $p width]} { set w [dict get $p width] } else { set w 1 }It gains precision: the original would also silently default if p were malformed or the subcommand misspelled, hiding a real bug behind a plausible value.
3. zz is a one-key lookup — absent simply means 0. a b is a path: Tcl must read a's value, 1, and treat it as a dictionary in order to look for b. A single word is an odd-length dictionary, so that step errors before any question about b is asked.
4. (a) default — a report without a colour preference is fine. (b) error — writing somewhere unspecified is worse than not running. (c) default — it is documented, so the absence is expected. (d) error — never invent a credential.
5.
proc require {d keys} {
set missing {}
foreach k $keys {
if {![dict exists $d $k]} { lappend missing $k }
}
if {[llength $missing]} {
error "missing required field(s): [join $missing {, }]"
}
}
if {[catch {require [dict create name smoke] {name target mode}} m]} { puts $m }missing required field(s): target, modeNaming all of them at once saves the reader three runs.
6. Because a key that exists with an empty value gives the same answer as one that is absent:
set cfg [dict create blank ""]
puts "exists: [dict exists $cfg blank]"exists: 1The test says "absent"; dict exists says it is present. Use dict exists.
10. Remember
dict exists $d keyreturns 1 or 0 and takes a value, not a variable name.- Use it to fill a default, either with an
ifthat modifies, or with? :when you only want the value. - Wrap the pattern in a small
dgethelper — Tcl 8.7 addsdict getwithdefault, but it does not exist on 8.6 or earlier. - Prefer
dict existstocatch—catchhides real failures behind the same path as an absent key. - A key with an empty value is present. Never test absence by comparing the value to
"". dict existsis a key test, not a type test — a path that descends through a non-dictionary value still errors withmissing value to go with key.- Decide deliberately between a default and a failure. Optional with a safe assumption: default. Absence means something is broken: let it stop.
Where this fits
Part of the Tcl curriculum.
