Lesson 2

The hidden `Composer` and mask parameters

Read changed masks and default masks without mistaking their bit layout for a public contract.

4 min readUpdated Jul 11, 2026

Status: A composable needs a composer context and change metadata to support the protocol. That division is Durable . Parameter counts, names, and bit positions are Version-specific .

Outcome

Given a source declaration such as this one, you should be able to point at the hidden values and explain their jobs:

@Composable
fun Greeting(name: String = "world", punctuation: String = "!") {
    Text("Hello, $name$punctuation")
}

The current compiler lowers the visible parameters, then appends a nullable Composer, one or more changed-mask integers, and default-mask integers when defaults need protocol handling. Those values are not reflection data. The compiler writes and consumes them directly.

Three kinds of information

Generated valueQuestion it answersSafe teaching level
ComposerWhich composition execution and runtime protocol are active?Durable role; exact type/signature Version-specific
$changedWhat does the caller already know about each argument?Durable role; masks Version-specific
$defaultWhich visible arguments were omitted and need their defaults evaluated?Durable role; bit assignments Version-specific

A changed mask lets the callee reuse comparisons the caller already performed. A callee may still call Composer.changed when a value is uncertain; stability inference means it is not simply “one boolean per parameter.”

A default bit means that a source argument was omitted, not that its expression is constant. State-reading defaults are surrounded by a defaults group so the runtime can track whether that calculation is invalid.

Read a captured default-parameter output

The pinned compiler golden DefaultParamTransformTests/testNonStaticDefaultExpressions.txt uses fun Test(x: Int = makeInt()). Its relevant captured lines are:

fun Test(x: Int, %composer: Composer?, %changed: Int, %default: Int) {
  %composer = %composer.startRestartGroup(<>)
  val %dirty = %changed
  if (%changed and 0b0110 == 0) {
    %dirty = %dirty or if (%default and 0b0001 == 0 && %composer.changed(x)) 0b0100 else 0b0010
  }
  if (%composer.shouldExecute(%dirty and 0b0011 != 0b0010, %dirty and 0b0001)) {
    %composer.startDefaults()
    if (%changed and 0b0001 == 0 || %composer.defaultsInvalid) {
      if (%default and 0b0001 != 0) {
        x = makeInt()

This is captured output with unrelated lines omitted. The golden renderer prints <> for generated integer values. At this revision, the first default parameter occupies the low bit of $default. That statement is about this golden and compiler revision, not a promise about future packing.

The call-site side is visible in ComposerParamTransformer.kt: withComposerParamIfNeeded appends the composer, changed masks, and default masks; mutateWithComposerParam rewrites the declaration.

Why masks exist

If a parent already knows name is unchanged, it can pass that state to the child. A restartable, skippable child can use it to run its body or call skipToGroupEnd.

This optimization protocol is why compiler and runtime must agree on methods and version markers. ComposeVersion.kt exposes the marker; VersionChecker.kt rejects a runtime below its fixed minimum instead of guessing.

Experiment: vary one input

Compile the same function with no defaults, one default, then two defaults with one omitted. Inspect golden output or IR for $default, startDefaults, and the omitted bit.

Control: make the defaults constants, then make one read state. Expected: the mask still records omission, while the state-reading default changes invalidation handling. Limit: three cases cannot establish a universal bit layout.

Misconceptions

  • “Changed masks are runtime reflection.” They are compiler-generated integer arguments.
  • “A default mask says the value equals the default.” It says the caller omitted the argument.
  • “Every composable has exactly one changed integer.” Large signatures and receivers can require more.
  • “The masks are an app-facing ABI.” They are a compiler/runtime protocol.

Check yourself

If a caller passes an explicit value equal to the source default, should the default mask mark that argument as omitted? Why? Then name the separate mask that can still tell the callee whether the argument is known unchanged.

Evidence notes

ClaimDirect evidenceStatus
The compiler appends composer, changed, and default parametersComposerParamTransformer.ktVersion-specific
Defaults are calculated in a defaults group and can be invalidatedComposer.kt, startDefaults, and pinned golden outputDurable Version-specific
Compiler/runtime compatibility is checked through a runtime version markerComposeVersion.kt and VersionChecker.ktVersion-specific

Freshness

Refresh this lesson when ComposerParamTransformer, ComposeVersion, VersionChecker, or golden output changes. Treat exact mask names, bit layouts, and optimization conditions as stale until rechecked.

Finished this lesson?

Your progress stays only in this browser.