# A field typed `unknown` reads back as a reference
**Symptom:** a field holds a real value — another piece, a nested object, the
screen a pattern just built — and every read of it through the declaring type
carries none of that value: it is truthy and compares equal to what it names,
but every property of it is `undefined`. No runtime error and no warning.
Rendering the same path still works, which is what makes this confusing:
`` shows the piece while `entry.piece.label`
right beside it is undefined. In a pattern test the same shape reads as `{}`:
`textContent(piece[UI])` is empty even though the piece on screen shows the
tree.
```typescript
// Shown at module scope.
interface Entry {
type: string;
piece: unknown; // holds a link to another piece
}
```
**Why:** `unknown` lowers to the schema `{ type: "unknown" }`, which declares a
reference rather than a value, and neither read path descends into one. What
comes back answers presence and identity: it is truthy when something is there,
`equals()` compares it to the cell it names, and writing it back stores a link.
It carries no properties, so reading one yields `undefined`.
That holds whatever sits behind the position — a stored string is as opaque as
a stored object — and it holds on both read paths, the eager traversal and the
lazy view a lift's argument goes through. An `asCell` alongside it still
produces a cell. A concrete type declared beside it, as in
`{ type: ["unknown", "string"] }`, is a reader asking for the value and gets
it; so does a branch of an `anyOf` that declares the property, which no longer
loses to a sibling branch that declines to look.
The path survives even though the value is not carried, which is why cells and
rendering still work: a `$cell` binding passes the path, and the renderer reads
it under a schema of its own (`asSchema(rendererVDOMSchema)` —
`runner/src/runner.ts` on the mainline path, `shell/src/views/BodyView.ts`, and
`html/src/in-process.ts` when the reconciler runs in the caller's own process),
while every read of the *value* comes back empty. An attribute or a property
the renderer reads through such a reference shows the string, number,
boolean, or null it names: a DOM attribute can hold only the value, so the
reconciler reads the reference's own position for the scalar there. A record
or a list behind the reference stays a reference.
Three transformer diagnostics cover neighboring cases and none catches this
one: `reactive-capture:unknown-type` reports a closure capture whose inferred
type is `unknown`, `schema:unknown-type-access` reports a property typed
`unknown` accessed directly on a lift or handler parameter
(`ts-transformers/src/transformers/type-shrinking.ts`), and
`pattern-result:opaque-reserved-key` reports the reserved-key case below.
Reaching the field through an array element inside a callback, as further
below, is outside all three.
## When the opaque field is the pattern's own screen
The same reference semantics reach `[UI]` and `[NAME]`, and at the root of a
pattern's own result they are the wrong reading. A reserved key's spelling
belongs to the framework rather than to whoever described it, so the value
under it there is one that pattern produced: the screen it built, the name it
chose. Declared `unknown`, that value is gone for every reader, while the piece
goes on rendering — the renderer reads `$UI` under a schema of its own. A
pattern test inspecting the screen, or another pattern reading it, gets `{}`.
```typescript
// Shown as alternative snippets.
// The screen this pattern renders, declared as a reference to someone else's.
type Wrong = { [NAME]: unknown; [UI]: unknown; label: string };
```
```typescript
// Shown as alternative snippets.
// The same result, naming what each field holds.
type Right = { [NAME]: string; [UI]: VNode; label: string };
```
The compiler rejects the first form: `pattern-result:opaque-reserved-key`,
raised at the `pattern()` call by `reserved-result-keys.ts` in
`packages/ts-transformers`. The rejection applies where an author can act —
`cf check`, deploy, candidate admission; the runtime's identity-pinned reload
of already-deployed stored source demotes it to a warning, so pieces that
predate the rule keep loading. It reaches the root of a **result** schema and
nothing else, which leaves two shapes legal and needed. Below the root, a
reserved key names a field of another piece, where `unknown` is what keeps the
field a reference to that piece's own screen rather than a copy, so the
controls in it stay bound to the piece that owns them. On the argument side, a
declaration has to keep accepting every value it accepted before, so a consumer
view of a result type holds `unknown` even where the producing type names
`VNode`. `unknown`'s own page states that producer/consumer split:
[`docs/common/concepts/types-and-schemas/unknown.md`](../../../common/concepts/types-and-schemas/unknown.md).
## What decides whether a read materializes
The schema of the **operand the value is read into**, not the schema the field
was declared with. A lift or handler that names the fields it wants gets them:
the read follows the link into the target and materializes them, and re-runs
when the target changes them.
```typescript
// Shown at module scope.
// The operand names the field, so this read follows the link and materializes.
const readLabel = lift<{ piece: { label?: string } }, string | undefined>(
({ piece }) => piece?.label,
);
```
Three rules decide whether such a read reaches the value:
**Name the property.** Naming it is what reliably overrides a declared field
schema. An operand of `entries?: any[]` can lower to `items: true`, and a `true`
schema defers to the schema the cell itself carries (`traverse.ts`,
`combineSchemaForLink`: a true reader schema adopts the link's schema) — with
`piece: unknown` still inside
it, so `entries[0].piece` is undefined. `entries?: { piece: any }[]` names the
property, and the piece materializes: the same read of
`entries[0].piece.nickname` yields the module's real nickname under the second
operand and `undefined` under the first.
Do not read `any[]` as always lowering to `items: true`. Type shrinking narrows
an operand from the accesses in the lift body, so a body that indexes a literal
position can get a structural schema that materializes, while the same
annotation with a variable index gets `items: true` and does not. Naming the
fields is what makes the read independent of that; `--show-transformed` below is
how to see which one you got.
**Apply the lift where the value is still a link.** A `.map()` at pattern-body
scope keeps each element reactive, so a lift applied inside it is a real lift
application on a link. The same `.map()` inside a `computed()` is not: the whole
`computed()` body becomes a single lift, and the `.map()` there is plain
JavaScript over the already-materialized list, where the field is already
`undefined`.
```typescript
// Shown at module scope.
const readEntryLabel = lift<{ piece: { label?: string } }, string | undefined>(
({ piece }) => piece?.label,
);
export default pattern<{ entries: { type: string; piece: unknown }[] }>(
({ entries }) => {
// Each element is still a link here, so the lift reads through it. The
// same expression inside a `computed()` would not.
const labels = entries.map((entry) =>
readEntryLabel({ piece: entry.piece as { label?: string } })
);
return { labels };
},
);
```
**In a handler, ask for a cell.** A handler receives its operands as values, so
there is no link left to follow. Type the field as a `Cell<...>` and call
`get()` on it:
```typescript
// Shown at module scope.
interface ListEntry {
type: string;
piece: unknown;
}
type EntryHandle =
& Omit
& { piece: Cell> };
const reportFirstLabel = handler<
{ result: Writable },
{ entries: Writable }
>(({ result }, { entries }) => {
result.set(entries.get()[0]?.piece?.get()?.label);
});
```
The declaring type and the operand type describe the same cell; the call site
casts between the two views. Casting **to** a `Cell<>` is rejected by the
transformer, so a handler that also builds new entries cannot take this view of
the list it writes: the entry it constructs would have to be cast into it.
## Confirming it
The emitted operand schema is the ground truth, and
`--show-transformed` prints it:
```bash
deno task cf check .tsx --show-transformed --no-run
```
A field you expect to read shows as `{ type: "unknown" }` when the read will
stop at a reference — the field itself is there and truthy, its properties are
not — and as the shape you named when it will materialize.
## The shape that invites it
A pattern that holds a list of other pieces and types the element's piece field
`unknown` — because the element can hold any of several kinds — hits every arm
of this at once. Each feature reading a field off one of those pieces gets
`undefined` and silently falls back, and because the fallbacks differ per
feature (a generic label here, an empty dialog there) the single cause is
discovered once per feature rather than once.
Two fixes apply, and which one you need depends on where the read is. A read
that can go through a lift takes the operand that names the fields, per the
rules above. A value the holder needs for itself — the label it chose for an
entry, say — is better recorded on the entry beside the piece than read back
through it.