Column metadata: a name, the column's logical DataType, and a declared
nullable flag.
nullable = false is a declared constraint that DataFrame::from_rows
enforces: row data placing a Scalar::Null in such a column raises
NullInNonNullable(name). (DataFrame::empty builds 0-row columns, so it
can never violate the constraint.) The flag is never inferred from a
column's contents: the constructor's nullable default — and so
DataFrame::DataFrame and the IO readers — always sets nullable = true,
so a nullable = false field only ever originates from an explicit
Field::Field(..., nullable=false) in a caller-supplied schema.
Once declared, it travels with the cells it describes. Every operation that
moves a column carries its field rather than re-deriving one:
Field::rename (and so DataFrame::rename / rename_with, which change
nothing but the name), Schema::select / Schema::rename, the row-only
frame transforms that reuse their input's schema verbatim (head / tail
/ slice / filter / sort / unique / fill_null / …), and the
projections: a select or with_columns entry that is a bare col("x")
(or an aliased one, which carries the field renamed), every column drop
or select leaves in place, and the counter-prepending with_row_index.
A column replaced through with_columns takes the field of whichever
column now supplies its cells.
An operation that computes cells derives a fresh field instead, with the
constructor default: arithmetic, aggregations, cast, fill_null as an
expression, group_by(...).agg(...), join (an outer one introduces
nulls), and the summary frames (describe / null_count / sum / …).
The declaration was made about the input's cells, not about these.
That split is what keeps the flag honest without ever inspecting a column:
it is validated once, by from_rows, and thereafter only accompanies cells
it was validated against — the operations that carry it drop, reorder or
rename, and never introduce a value.
The fields are priv, so the struct is opaque outside this package: build
one through Field::Field(...) and read it through name() / dtype() /
nullable(). That is what actually keeps a future field additive — a
readable field is also matchable, and MoonBit requires a struct pattern
to name every field or carry .., so a public field's arrival would break
a caller's pattern exactly as a new pub(all) enum variant breaks a
match.