Author: Florent Merlet <[email protected]>
Author: Claude (Anthropic)
State: Draft
Type: Project
Vote: Pending
Created: 19-Oct-2026
Tcl-Version: 9.2
Keywords: annotation, type, typed variable, path, struct
Post-History:
Abstract
This TIP adds annotations to Tcl: a prefix {text}word that attaches metadata to a word without changing its value. An annotation resolved by a variable constructor is a type, and gives the variable a contract checked on every write; an unresolved annotation is a note that travels with the value. A small declarative type language (type def) describes enumerations, sums, products (structs), typed lists, typed dicts, variable links and parameterized types. Parts of a typed value are read and written with a path in the array index: $R(A.x), set R((A,B).x) 10 30.
Both new syntaxes take over forms that are syntax errors in Tcl 9.0 ({x}word) or fall back exactly to the Tcl 9.0 rule ($name(...)). No valid script changes meaning, and code that uses no annotation sees no difference.
Binary representations (type binary, chan binary) are built on this TIP but are left to a separate TIP.
Rationale
"Everything is a string" keeps Tcl simple, but the developer always knows more about a value than its string says: that 12 is in millimetres, that a list holds only integers, that a dict must have a name key. Today this knowledge lives in comments and in hand-written checks scattered through the code. Errors surface far from where the bad value was stored.
An annotation puts this knowledge beside the value, as metadata, without touching the string. The word comes from Latin ad notare, "to put a note beside"; the Greek protokollon was likewise the sheet glued to the head of a papyrus roll, describing it without being part of it. An annotated value has a raw part, the string every command sees, and an added part, read only by commands that ask for it.
The proposal is driven by four needs:
- Check where values are stored.
set {int}n 5givesna contract;set n abcthen fails at the faulty write, not later. - Name what is built.
set {pt}a {age}12 {name}Jeansays which value goes where, in the source, never guessed from the data. - Reach the parts of structured data.
$R(A.x)andset R((A,B).x) 10 30replace chains ofdict get,lindexandlset. - Tell kinds of values apart.
type def mm {quantity(L, 0.001)}andtype def kg {quantity(M)}are both doubles, but akgis never stored where anmmis expected.
The design rule throughout: annotations live outside the plane of raw values. A computation (expr, string toupper) returns a bare value; only copying, list access and argument passing keep the annotation. Scripts that use no annotation run exactly as before.
Specification
1. Annotated values
Syntax. A word of the form {text}rest, where rest is not empty, is an annotated word. text is the annotation; rest is parsed as an ordinary word (literal, $var, [cmd], "...", {...}). {*} keeps its meaning. The value of the word is the value of rest: the annotation never appears in the string.
puts {mm}12 ;# 12
string length {mm}12 ;# 2
Glued annotations {a}{b}$x are not a syntax: the form would be ambiguous with {int}{300}, an annotation on a braced literal. Annotations stack by annotating a value that is already annotated.
Notes. An annotation that no constructor has resolved is a note. It follows the value through copies, arguments, return values and list elements. Notes stack, from the oldest to the most recent.
set x {mm}5
set y {px}$x
note get $y ;# mm.px
Types. A variable constructor resolves annotations against the types the interpreter knows (section 2). The constructors are set {T}v ... and set v {T}$x, binding of proc arguments, lassign, foreach, and in expr the assignments x = v and x is T = v. Outside a constructor, {T}$x checks and converts nothing, even when T is a known type: T is pushed like a note, at no cost.
Two stacks and unstacking. An annotated value carries at most one resolved type and a stack of pending notes; they are displayed as [email protected]. A constructor:
- takes the most recent note and tries to resolve it;
- continues while notes resolve; the first that does not stops the process and stays on the stack;
- checks that the unstacked types are consistent, else
contradictory annotations A and B; - casts the value to the most recent type;
- lets the variable's contract make the final check.
A note set before its type def is therefore resolved by the next constructor that sees it.
Context first. A constructor resolves an annotation in its context before the global types: the variant names of a sum ({i}5), the field names of a product ({age}12). A global type of the same name changes nothing.
Local annotations. An annotation whose text contains a character that type names refuse (-, ?, :...) can never be a type. It is local: it passes from a caller to the called command and is not stored. Two families are intended, with no possible collision with a type name: options {-name} and questions {name?}. Storing or declaring with a local annotation is an error (local annotation {-v} cannot be stored by "set").
The empty annotation {}. On a value in a constructor (set v {}$x), it removes the resolved type and keeps the notes. On a variable name (set {}v $x), it lifts the variable's contract. On a value outside a constructor, the reader sees the bare content. The original value is never modified.
The note command.
note get VALUE the notes, oldest first, joined by "."
note has VALUE NAME 1 if NAME is one of the notes, else 0
note delete VALUE ?NAME? the value without the note NAME, or without any note
note delete returns a new value; Tcl values stay immutable.
2. The type command and the type language
type def NAME DEFINITION define a nominal type, or a parameterized family
type alias NAME DEFINITION give a structural name to a definition
type get VALUE the resolved type of a value (empty if none)
type cast TYPE VALUE a constructor's resolution, without a variable
type convert FROM TO LAMBDA declare an explicit conversion
type deref VALUE a snapshot: each var link replaced by its value
type def creates a nominal type: type def port int(u,16) is distinct from int(u,16). type alias only names a definition. In expr, v is T tests a variable's type; x is T = v declares and assigns.
Base types. int (unbounded), double, string, list, dict (the ordinary Tcl list and dict), var (a link to a variable, by name), any (an element of a heterogeneous list.any). Refinements restrict a base type: int(u|s, n), double(32|64), string(ENC, N|z), bitarray(n). Their binary representation is outside the scope of this TIP.
Composition language. A definition is parsed on the model of expr. Operands are types (bare words), names (a word glued after }: {int}age) and values (quoted, braced, or after =). Operators, from the strongest:
| operator | role | example |
|---|---|---|
. |
nesting (right-associative) | list.dict.int |
( ) |
arguments, glued to the name | list(3).int, dict(x, y?) |
= or ?= |
default value of a field | {int}age ?= 0 |
; |
product | {double}x; {double}y |
, |
sum | {int}i, {string}s |
{ }name |
grouping, named by the word glued after } |
{{int}x; {int}y}A |
; and , have the same precedence; mixing them requires braces. A type name contains only ASCII letters, digits, _ and non-ASCII bytes, and does not start with a digit. Any other ASCII sign outside the grammar is refused with TCL PARSE TYPE BADCHAR; this is what keeps local annotations safe. A definition is declarative: nothing is substituted when it is read, except in default values.
Containers.
type def color {"red", "green", "blue"} ;# enumeration
type def S {{int}i, {string}s} ;# sum: one variant at a time
type def pt {{string}name ?= "?"; {int}age ?= 0} ;# product
set {list.int}L 10 20 30 ;# typed list
set {list(2).int}c 3 4 ;# list of exactly 2
type def record {dict(name, age?=0).string} ;# dict: name required, age optional
set A 1; set {list.var.int}V A ;# link: V(0) reads ::A
The elements of a list.T are atomic: lindex, foreach and $L(i) return typed elements, never split again. Dict keys are always text; only values are typed.
Default values. A default is a literal, a $variable or a [script], evaluated at each construction. A field with no default must be given: field "x" of pt has no value and no default.
Parameterized types. The name in type def may declare parameters, written like fields and used with $ in the body. Types may be recursive.
type def {pair({type}T)} {{$T}x; {$T}y ?= 0}
set {pair(double)}p {x}1.5 ;# 1.5 0
type def {tree({type}T)} {{$T}value; {list.tree($T)}children}
Phantom parameters. A parameter absent from the body distinguishes instances without changing values. Units of measure follow:
type def {quantity({string}dim, {double}f = 1.0, {double}o = 0.0)} double
type def mm {quantity(L, 0.001)}
type def m {quantity(L)}
type def kg {quantity(M)}
type convert quantity quantity {{x from to} {
if {$from(dim) ne $to(dim)} {return -code continue}
expr {($x * $from(f) + $from(o) - $to(o)) / $to(f)}
}}
set {mm}a 1500
set b {m}$a ;# 1.5
set {m}c $a ;# type mismatch: expected m but got mm (use an explicit cast)
set d {kg}$a ;# unknown way to cast the value from mm to kg
A type convert rule targets a type or a family; return -code continue means "not applicable". Conversions between nominal types are always explicit.
3. Typed variables and constructors
Contract. A variable declared with set {T}v ... checks every later write, whatever its origin: set, lappend, lset, incr, dict set, a path write, or expr {v = ...}.
set {list.int}M 1 2
lappend M 3 ;# 1 2 3
lappend M x ;# expected integer but got "x"
Widening and casts. A refinement goes where a wider type is expected (int(u,8) into int); narrowing needs an explicit cast. A nominal type gives way to its base, but two nominal types with no base link are never equivalent. set v {T}$x and type cast T $x try, in order: a type convert rule, the chain of bases, a sum's variant, a family rule, the built-in scalar conversions. Otherwise: unknown way to cast the value from A to B.
What keeps the annotation.
| operation | type | notes |
|---|---|---|
| copy, argument, return value | kept | kept |
lindex, foreach, $L(i) on a list.T |
element typed T |
- |
string cat $x (one argument) |
kept | kept |
computation (expr, string toupper, dict set on a value) |
lost | lost |
cast {T}$x in a constructor |
changed | kept |
Value types and contracts. A value keeps its type wherever it goes, but only an annotation on a variable's name gives the variable a contract. In set A $B or set A {T}$B, A holds a value of type T (type get $A), and any later write to A is still accepted. Procedure arguments follow the same rule: an argument receives typed values without becoming strict. foreach, lmap, dict for and dict map forget the type of their loop variable before each assignment.
set {list.int}L 1 2 3
set e [lindex $L 1] ;# e holds a value of type int
type get $e ;# int
set e hello ;# accepted: e has no contract
set {int}n [lindex $L 1] ;# the contract comes from the name
set n hello ;# expected integer but got "hello"
Declaring in any writing command. A type annotation on the variable name declares the variable in every command that writes it, not only in set: lappend {list}M x is set {list}M $M followed by lappend M x. The new contract is built from the current content and replaces any previous one; {}M lifts it. This is done once, where every access by name converges (TclObjLookupVarEx), so lappend, lset, incr, append and dict set behave alike.
set {list.int}L 1 2 3
set M $L ;# M holds a list<int> value, without a contract
lappend M x ;# 1 2 3 x: a modification is a computation, M is now a plain list
lappend L x ;# expected integer but got "x": L has a contract
lappend {list}L x ;# 1 2 3 x, L is now declared list
incr {int}n ;# n receives the contract int
dict set {dict.int}D b 2 ;# D is now dict<int>
A modification is a computation. Without a contract, lappend, lset, incr, append or dict set on a typed value return a plain value, as expr does; they never fail because of the value's type. Under a contract, the result is checked when it is stored, and the variable keeps its old value if the contract refuses it. Seen from a command that ignores annotations, a value is its string; only declarations, visible in the source on a name, add checks.
Collections and elements. A declaration says what an element is. Only collections (list, dict) show their elements to the list view (llength, lindex, foreach, lassign, {*}); a scalar, a product or a link is one element, and a sum takes the nature of its active variant. The parts of an element are reached by path: $P(x), or $P(*) for all of them in order.
type def Point {{int}x = 0; {int}y = 0}
set {Point}P 100 7
llength $P ;# 1: a point is one element
set {list.string}L {hello world} b
llength [lindex $L 0] ;# 1: "hello world" is one string
deux {*}$P(*) ;# the fields as two arguments
expr {(x,y) = $P} ;# tuple assignment reads the fields
This separates two entities that untyped Tcl lists conflate, and removes a family of bugs (a string split into words, an element dug into, a misplaced {*}). Undeclared values keep the Tcl rule: their list view is the list view of their string.
Building a product. A product of n fields is built in one of these forms; nothing is guessed from the data.
| form | example | rule |
|---|---|---|
| positional | set {pt}a Jean 12 |
exactly n values, in field order |
| field tags | set {pt}a {age}12 {name}Jean |
every value tagged, any order; omitted fields take their default |
| one field | set {pt}a {pt.name}Jean |
the other fields take their default |
| path + positional | set {pt}a(age,name) 12 Jean |
one value per leaf of the path, or per group |
| no argument | set {pt}a |
every field at its default |
| copy | set {pt}a $b |
$b already of type pt |
Anything else is wrong # args: pt needs 2 values; to name fields (the others take their default), write {field}value or set {pt}var(field,...) value....
Field tags. The tag of an argument is its most recent annotation, read in the context of the product, so a field name wins over a global type of the same name. A tag carried by a variable counts. Tags are all or nothing:
type def couple {{string}name; {int}age ?= 0}
set {couple}p {name}jean {age}12 ;# jean 12
set {couple}p {name}zoe ;# zoe 0
set {couple}p {name}jean 12 ;# can't mix values named by a field annotation ...
set {couple}p {name}a {size}3 ;# unknown field "size" in couple
set {couple}p {name}a {name}b ;# duplicate field "name" in couple
Path and positional values. set {T}v(path) v1 v2 ... builds the product and its nested products. The number of values chooses the distribution: one per leaf, one per element of the head group, or one for the whole path. Each value has the shape its read would return.
type def rect {{{int}x; {int}y}A; {{int}x; {int}y}B}
set {rect}R(A.x,A.y,B.x,B.y) 1 2 3 4
set {rect}R((A,B).(x,y)) 1 2 3 4
set {rect}R(A.(x,y),B.(x,y)) {1 2} {3 4}
Sums and lists. A sum takes its variant as a tag or in the type's name (set {S}v {i}5, set {S.s}w abc). Without a tag, the first variant whose shape fits is chosen. A list takes its elements one by one (set {list.int}L 1 2 3); a single argument is one element.
4. Paths
A typed value is read and written with array syntax; for a typed scalar variable, the index is a path. The language is the one proposed on the Tcl wiki page A better way to do indexation, parsed on the model of expr.
set {rect}R(A.x,A.y,B.x,B.y) 1 2 3 4
$R(A.x) ;# 1
set R(B) {5 6} ;# write a whole field
set R((A,B).x) 10 30 ;# multiple write
${R(A.x, B.y)} ;# read; spaces allowed
Operands. A bare word is a field, a dict key, a variant name or a list index (3, end, end-1): the container's type decides. 'key' is always a key ('end' is the key "end"). "tag" is literal text copied into the result.
Operators, from the weakest:
| operator | role | example |
|---|---|---|
, |
collection (a group) | A,B |
* (unary) |
unfolding: the group merges into its parent | *0:2,4 |
. |
nesting; distributes over a group | A.(x,y) = A.x,A.y |
: |
list range | 0:end-1 |
( ) |
grouping | (A,B).x |
A multiple read returns lists nested like the groups: $R((A,B).(x,y)) gives {1 2} {3 4}; $L(0:2,4) gives {10 20 30} 50; $L(*0:2,4) gives 10 20 30 50.
Per container. Product: $a(name), $R(A.(x,y)); sum: $v(i) reads the current variant; list: $L(2), $L(1:3), $L(0,2); dict: $D(a), $D('end'); bitarray: $b(3), $b(1:4). Writes go through the variable's contract and are revalidated. Reading then writing back the same path always works: set R(c) [set R(c)].
A path substitutes nothing. Tcl substitutes $x, [cmd] and [(expr)] in $R(...) and in bare words; each substituted value is one leaf, never parsed again as a path. In braces and in ${...}, $ and [ are errors. Data is therefore never executed by the path parser.
set k file.txt ; $D($k) ;# the key "file.txt", not D.file.txt
set idx {0 2} ; $L(*$idx) ;# * unfolds a list value
set p {[exec ls]} ; $L($p) ;# nothing runs: bad index "[exec ls]"
A computed path is read by naming the variable with it: set ref R($path); set $ref.
Spaces. Spaces are allowed in $R(...), ${R(...)} and in an annotated word {T}R(...). In a bare word (set R(A.x, A.y) ...) Tcl splits at spaces as it always has.
Arrays are unchanged. For an array, or for an index that is not a well-formed path, the Tcl 9.0 rule applies exactly. In particular a parenthesized index is not an expression: set A((1+1)) 3 keeps the key (1+1); a computed index is written A([(1+1)]) (TIP 759).
5. C level
No new stub functions. The public C API (tcl.decls) is unchanged. The additions visible from C are two token types and one object type.
Parser tokens (tcl.h):
| token | value | produced by | meaning |
|---|---|---|---|
TCL_TOKEN_ANNOT |
512 | Tcl_ParseCommand, ParseTokens |
the annotation of a word {text}rest; the tokens of rest follow |
TCL_TOKEN_INDEX |
1024 | Tcl_ParseVarName, only when the extended index syntax is used |
wraps the components of an index $name(...); an index valid in Tcl 9.0 keeps exactly its Tcl 9.0 tokens |
Tools that walk token arrays (testparser, static checkers) must accept these two types.
The annotated object. An annotated value is a Tcl_Obj of type annotated. Its string is the string of its content, so any code that ignores annotations sees the content.
Tcl_Obj (typePtr = &annotatedObjType)
bytes/length the content's string, computed on demand
twoPtrValue.ptr1 the content V: an ordinary, shared Tcl_Obj
twoPtrValue.ptr2 the annotation, one of:
- a resolved type reference
- the raw text of an unresolved annotation
- "" (the empty annotation {})
- a state {T, N}: resolved type T (or NULL) and notes N
The object is immutable and shared: Tcl_DuplicateObj shares the state, and annotating, unstacking or removing a note makes a new object. setFromAnyProc refuses: an annotated value is born only from the parser or a typed assignment (TCL VALUE ANNOTATED). The type is a Tcl 9 abstract list (TCL_OBJTYPE_V2), so lindex, llength, lrange, foreach and lset return typed elements of a list.T. Core conversions (Tcl_GetIntFromObj, dict and string access) read the content and never convert an annotated value in place.
Compilation.
- A word whose rest is literal compiles to a raw note (
::tcl::annotraw); otherwise to::tcl::annotate text value. - A
setwith an annotated name, andTcl_SetObjCmdwith an annotated first argument, call the constructor. - New instruction
INST_INDEX_CONCATbuilds a path index that remembers which spans were substituted, so a substituted value stays one leaf.TclSubstTokensproduces the same object when not compiled. - New instruction
INST_TYPE_FORGETforgets a loop variable's type before assignment (compileddict for,dict map). INST_STORE_SCALARtakes the slow path only for an annotated value; writes to a typed variable go through its contract (tclVar.c).
Error codes. Type errors use TCL TYPE ... (INT, SUM, DICT, LIST, FIELD, PATH, MISMATCH, CONTRADICT, UNKNOWN, LOCALANNOT, STALE, DEREF) and TCL PARSE TYPE BADCHAR. Path parsing errors use TCL PARSE INDEX ...; wrong argument counts use TCL WRONGARGS.
Compatibility
No valid script changes meaning. Both syntaxes take over forms that Tcl 9.0 rejects or reads the same way:
{x}wordis a syntax error in Tcl 9.0 (extra characters after close-brace), except{*}, which keeps its meaning.- An extended
$name(...)index falls back exactly to the Tcl 9.0 rule as soon as it is not a well-formed path, and an array is never read as a path.info completegives the same answers. Bare words are not affected:puts a(b c)is still two words. - Commands and values that carry no annotation behave as in Tcl 9.0; an annotated value passed to an unaware command is seen as its content.
Checks on the reference implementation.
- Tcl test files
parse,parseExpr,parseOld,subst(including 257testparsertests),var,compile,set,upvar,basic,execute,incr,append,lset,dict,info: results identical to the base. The one known failure,info-20.2, comes from the extra math functionspopcountandbitlenand is present in the base too. - Equivalence with a stock Tcl 9.0 on 18,000 generated cases (
$z(...),"<$z(...)>",set z(...),info complete): no valid result changed. - AddressSanitizer clean; memory stable over 1,000,000 constructions.
Dependencies. The expr assignments x = v and (a,b) = $L, and the [(expr)] substitution, come from TIP 759. This TIP adds v is T and the declaring assignment x is T = v. Without TIP 759, typed variables are declared with set only.
Performance. The cost for unannotated code is one test on the object type in the hot paths (INST_STORE_SCALAR, conversions). It has not yet been measured against stock Tcl 9.0; this is an open issue.
Reference Implementation
A prototype on core-9-0-branch with the TIP 759 branch, by Florent Merlet with Claude. It adds seven files (about 15,000 lines of C) and hooks in about twenty core files.
| file | content |
|---|---|
tclAnnotObj.c |
the annotated object, the {T, N} state, resolution, note |
tclTypes.c |
typology, the type command, validation, widening, casts |
tclTypeParse.c |
parser and compiler of type expressions |
tclTypeGeneric.c |
parameterized types, instances, phantom parameters |
tclTypedVar.c |
typed variables and constructors |
tclTypePath.c |
path language: read, write, construction, path index object |
tclVarRef.c |
var links, including links to variables that are gone |
The main core hooks are in tclParse.c (tokens), tclCompile.c and tclCompCmdsSZ.c (compilation), tclExecute.c (instructions), tclVar.c (the contract), tclProc.c (typed arguments) and tclCompExpr.c (is).
Rejected Alternatives
| alternative | why not |
|---|---|
| a new quoting rule for annotations | {x}word takes over a syntax error and needs none |
glued stacking {a}{b}$x |
ambiguous with {int}{300} |
resolving types wherever {T}$x appears |
checking belongs where the value is stored; elsewhere the annotation is free and never fails |
the bare name/value form set {pt}a name Jean age 12 |
read two ways when n arguments match n fields; names are now written in the source |
| mixing field tags and positions | a disguised positional form |
a path that substitutes $ and [ |
data would be executed by the path parser |
(expr) as an index |
breaks the braces rule, clashes with path grouping, and changes Tcl 9.0 keys such as (1+1) |
path + field tags (set {rect}R(A) {y}2 {x}1) |
no proven need yet |
Open Issues
- Contracts in the reference implementation. The prototype still gives an untyped variable the contract of a typed value it receives (
set e [lindex $L 1]; set e hellofails). Aligning it with section 3 (no adoption, declaration in any writing command) is tested in a separate patch, with no regression in the Tcl test suite; a variable that does not exist yet does not receive a contract this way. ;and,mixed without braces are accepted and produce an anonymous field; the grammar says they should be refused.- Reading an inactive variant of a sum gives
unknown way to cast the value from S to stringinstead of naming the variant. - Variants of the same shape: without a tag, the first is taken silently.
set $rwhere$ris an annotated value is taken as a constructor.- A general "no value" (
opt(T)exists only for binary links); notes per list element. - Cost of the hooks for unannotated code, to measure against stock Tcl.
- Binary representations (
type binary,chan binary, C codecs): separate TIP.
Copyright
This document has been placed in the public domain.
