Data masking
This page is a sketchpad for checking non-standard evaluation, also called data masking, with data.table as the most pressing target. The framing, a four-step design ladder and a survey of precedent, is section 6 of open type-system questions.
What ships today
Section titled “What ships today”ry recognizes masking and refuses to check inside it, so there are no false positives, and no checking inside the mask. Four mechanisms do the work:
quiet_readsin naming. A read inside a masked context is never reported as unresolved, and still keeps its lexical definitions alive.masked_subsetsin naming. A[bracket carrying an unambiguous data.table signature masks its index arguments. The signature is aby =orkeyby =argument, a:=call, a.()call, or one of the specials.SD,.N,.I,.BY,.GRP, and.EACHI. When the subject is unknown, the whole bracket types asUnknown.- The
@maskedstub attribute. It marks a variadic callee whose...is data-masked, which covers the dplyr verbs. The base masking family (with,within,subset, andtransform) is still hardcoded in the naming walk instead. - The result-shape classifier for data.table brackets. A conditional
data.tablestub namespace, active only when the project declares or attaches the package, gives values thedata.tableclass. A bracket whose subject has that class masks all its index reads, and its result is classified by the syntax ofjwithout knowing any column: noj, a.(…)orlist(…)call, or a:=call keeps thedata.tableclass, and anything else isUnknown. The classifier lives ininfer_index, the typing reference’s data-masking section is its contract, and thedatatablegroup in the typing-imports fixture suite pins it.
Every step below must keep the zero-false-positive property: an unknown masking construct stays silent and is never guessed at.
data.table
Section titled “data.table”The surface to model
Section titled “The surface to model”DT[i, j, by] is a whole query language in one bracket:
| Piece | Meaning | Static handle |
|---|---|---|
i |
the row filter or join, as in x > 3, another table, or on = |
a masked expression over columns |
j |
select, compute, or assign, as in x, .(m = mean(y)), or x := … |
decides the result shape |
by and keyby |
grouping | changes what .N and .SD mean, not the result class |
| specials | .SD, .N, .I, .BY, .GRP, .EACHI, .SDcols |
vocabulary data.table injects itself |
:= |
assignment by reference, which mutates DT in place |
the subject’s type evolves across statements |
set*() |
setnames, setkey, setDT, set() |
the same in-place evolution, in ordinary call syntax |
| chaining | DT[…][…] |
one bracket’s result shape feeds the next |
:= and the evolution problem
Section titled “:= and the evolution problem”DT[, y := x * 2] adds a column in place. A column-aware checker must treat this like loop-carried
and top-level rebinding: after the statement, the binding’s type is the old row type extended with
y. Three consequences need working out:
- Aliasing.
DT2 <- DT; DT2[, y := 1]changesDTtoo. One honest option is to refuse any column-level claim once an alias escapes. The other is to treat adata.table’s column set as a lower bound only (“has at least these columns”), which is the compromise pandas-stubs makes and probably the right one here: membership checks stay useful, and exactness is never claimed. - Deletion.
DT[, y := NULL]breaks the monotonicity a lower bound needs. Under the lower-bound model, a deletion must widen the whole binding back to “columns unknown”, unless exact sets are tracked only inside a linear region where no alias escapes. - The
set*()functions are the same problem in ordinary call syntax. Their stubs can carry the same contract once the contract language can express it.
Where column knowledge comes from
Section titled “Where column knowledge comes from”This is step 2 of the ladder, applied to data.table. The sources, cheapest first:
- A literal constructor, such as
data.table(x = 1:3, y = "a")oras.data.table(list(...)), gives exact names and exact element types. - A
:=with a literal left-hand side, a name or a character vector such asc("a","b"), extends the set, and so does the functional form`:=`(a = …, b = …). - An annotation. Today that is
#: DT: data.table. Once question 3 on data frame modeling settles a syntax for row types, it becomes something like#: DT: data.table<x: integer[], …>. This is the escape hatch forfread()and friends, which are opaque statically; an F#-style schema import at compile time is out of scope. - A derived shape, from a join,
melt, ordcast. These come late, are hard, and have low priority.
With a vocabulary, the first fact that becomes checkable inside the mask is membership, and a
misspelled column is the dominant real-world bug in non-standard evaluation. A bare name in i, j,
or by is checked against the column set, with the lexical environment as a fallback, because
data.table looks an unmatched name up lexically. A name is flagged only when it is in neither scope,
which keeps the zero-false-positive property.
Suggested order for data.table
Section titled “Suggested order for data.table”- Declare the bracket semantics and the
set*()contracts in the stub language, so they move out of the naming walk and into per-package stub metadata. The@maskedattribute already covers the variadic verbs; the bracket is what is still hardcoded. - Add the column vocabulary and membership checks (where column knowledge comes
from), with
:=evolution under the lower-bound model above. The row-type syntax now exists: ry’s built-inDataFrame{x: integer[], ...}type has exact column sets, checked column references and query result types (the “Frames” section of the typing reference). Reusing that machinery for adata.tableannotation is the remaining step, with the lower-bound model replacing exact sets, because adata.tableupdates by reference.
- What ships. A conditional
dplyrstub namespace declares the verbs@masked, with class-preserving signatures of the form<T> fn(.data: T, ...) -> T. A join preserves the left class, andjoin_byis a mask with no formals, so every argument is a column reference. The tidy-select helpers and the rest of the vocabulary are declared too. The@maskedcontract knows about formals: one declared before...resolves normally, by position or by name. Together with the native-pipe desugaring,fread(path) |> mutate(r = a / b)keeps its class end to end. - Remaining. A column membership check will reuse the future column-vocabulary machinery, with
the
.data$xand.env$xpronouns resolving exactly. Per-verb result shapes over row types come with design question 3:mutateextends,summarisecollapses, andselectprojects. - Refused indefinitely. Tidy-eval injection (
!!and{{ }}) stays refused, andacross()is declaredAnywith its arguments masked.
Builder EDSLs such as ompr: suppression is the answer
Section titled “Builder EDSLs such as ompr: suppression is the answer”There is no data context at the call site. add_variable(model, x[i], i = 1:10) declares x into
the model object, so the name lives in a value rather than in a frame. Keep the quiet-read
suppression. A package-specific extension could thread the declared names through the builder
chain’s return type, but that would be bespoke for each package, and it is not on any near-term path.
Open questions
Section titled “Open questions”- Should a column set be a lower bound, or an exact set inside a linear region? Pick one before adding column vocabularies; the current lean is the lower bound.
- The annotation syntax for row types belongs to design question 3. Non-standard evaluation should use whatever that question settles, rather than invent a second syntax.