diff --git a/website/content/docs/language-guide/02-fields.md b/website/content/docs/language-guide/02-fields.md index abd648c..3276472 100644 --- a/website/content/docs/language-guide/02-fields.md +++ b/website/content/docs/language-guide/02-fields.md @@ -18,6 +18,42 @@ record user { } ``` +## Nullable Fields + +ATProto distinguishes three states for a field: + +- **Absent** — the field is omitted from the JSON object. +- **Present with a value** — the field is set. +- **Present but null** — the field is explicitly `null`, which is a distinct signal from absent. + +Use `T | null` to declare that a field may hold an explicit `null`: + +```mlf +record user { + handle!: string, + displayName: string | null, // optional; if present, may be null + bio: string | null, +} +``` + +When combined with constraints, the `| null` appears after the constraint block: + +```mlf +record profile { + displayName: string constrained { + maxLength: 64, + } | null, +} +``` + +Semantically: +- `handle!: string` — required, must be a non-null string. +- `displayName: string` — optional; if present, must be a non-null string. +- `displayName: string | null` — optional; if present, may be a string or `null`. +- `displayName!: string | null` — required to be present; value may be a string or `null`. + +In the generated ATProto Lexicon, nullable fields appear in a top-level `nullable` array alongside the object's `required` array. MLF's `T | null` syntax folds that sibling array into the field's type position for readability. + ## Primitive Types MLF supports several primitive types: @@ -65,7 +101,8 @@ record example { **Null:** ```mlf record example { - nothing: null, // Always null (rarely used) + nothing: null, // Always null — rare as a standalone type; see + // "Nullable Fields" above for the common `T | null` form } ``` diff --git a/website/content/docs/language-guide/05-unions.md b/website/content/docs/language-guide/05-unions.md index db30c74..4081509 100644 --- a/website/content/docs/language-guide/05-unions.md +++ b/website/content/docs/language-guide/05-unions.md @@ -85,6 +85,20 @@ record embed { Though defining types separately is often cleaner. +## Special Case: `T | null` + +`null` as a union member has dedicated semantics — it marks the field as +nullable, not as a discriminated union of `T` or the `null` primitive. +See [Nullable Fields](/docs/language-guide/fields/#nullable-fields) for +details. The `| null` clause is structural, not a regular ref member, and +is always lowered to the ATProto spec's top-level `nullable` array. + +```mlf +record profile { + displayName: string | null, // nullable field, not an open union +} +``` + ## Unions in Arrays Unions work in arrays: diff --git a/website/content/docs/language-guide/11-lexicon-mapping.md b/website/content/docs/language-guide/11-lexicon-mapping.md index fa695dd..dc071d0 100644 --- a/website/content/docs/language-guide/11-lexicon-mapping.md +++ b/website/content/docs/language-guide/11-lexicon-mapping.md @@ -313,6 +313,43 @@ def type postMetadata = { - External references → `"namespace#defName"` - Imported types → Resolved to full namespace +## Nullable Fields + +`T | null` in MLF lowers to the object's top-level `nullable` array, +alongside `required`. See [Fields → Nullable Fields](/docs/language-guide/fields/#nullable-fields) +for the conceptual model. + +**MLF:** +```mlf +record user { + handle!: string, + displayName: string | null, + bio: string | null, +} +``` + +**Generated JSON:** +```json +{ + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["handle"], + "nullable": ["displayName", "bio"], + "properties": { + "handle": { "type": "string" }, + "displayName": { "type": "string" }, + "bio": { "type": "string" } + } + } + } + } +} +``` + ## Unions **MLF:**