Skip to content
Merged
6 changes: 6 additions & 0 deletions docs/src/content/docs/mappings/collections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ model FixtureRoot {
| `string[]` | Array or list of scalar values. |
| `FixtureContent[]` | Array or list of nested model values, including polymorphic dispatch when the item type is discriminated. |
| `Record<unknown>` | Dictionary-shaped metadata with unknown values. |
| `Record<string>` | Dictionary with string values. |

Generated tests exercise scalar arrays, nested object arrays, dictionaries, and polymorphic collections from fixture samples.

In C#, `Record<string>` emits `IDictionary<string, string>`. Loading a null entry
throws an error that includes its key path. `Record<string> | null = null` allows
the whole dictionary to be absent or null, but its entries still cannot be null.
Use `Record<unknown>` for values that may include null.

## Unknown-field passthrough

A model whose string indexer is `unknown` round-trips fields that it does not declare, so a document
Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/mappings/unions-polymorphism.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ union FixtureStatus {

`@parseAlias` is parse-only. Loading accepts `complete`, but saving emits the canonical TypeSpec value `ready`.

String literal aliases keep their declared name when combined with `null`.
For `alias Mood = "calm" | "busy";`, a field `mood: Mood | null` uses the generated
type `Mood`, not `mood`. Regenerating code produced by 3.0.0 can change these
property-derived type names; update any direct references to the old names.

Go and Swift declare an enum shared by several models once in their package or
module. Rust also declares it once and imports it into the other model files.
Use Rust's generated module re-exports rather than depending on which model file
owns the enum.

## Open string unions

```typespec
Expand Down Expand Up @@ -55,6 +65,11 @@ model ImageContent extends FixtureContent {

Typra uses the discriminator field to load and save the right child shape. In the fixture contract, `FixtureRoot.content` and `FixtureRoot.contentItems` exercise both a single polymorphic value and a polymorphic collection.

Rust stores a single polymorphic reference as raw `serde_json::Value`. Saving its
parent preserves that JSON without filling in nested defaults, including absent
`T | null = null` fields. Direct model loading and typed polymorphic collections
do apply those defaults. This limitation is unchanged.

## Generated shape

<Tabs syncKey="typra-target">
Expand Down
8 changes: 7 additions & 1 deletion docs/src/content/docs/reference/runtime-semantics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ be explicit in diagnostics, the conformance matrix, or a tracked known failure.
- A required TypeSpec property must be present when loading object-shaped data.
Missing required fields must fail; generated runtimes must not fabricate a
value to make construction succeed.
- Defaults do not satisfy required-field presence. Until complex defaults are
- Defaults do not satisfy required-field presence (the one exception is
`x: T | null = null`, below). Until complex defaults are
supported end-to-end, a default on a model-typed or collection-typed property
is a portability error rather than an implicit object factory.
- Optional properties have distinct wire states: absent, present with a value,
Expand All @@ -33,6 +34,11 @@ be explicit in diagnostics, the conformance matrix, or a tracked known failure.

## Nulls and unknown values

- `x: T | null` is a required key whose value may be null: load rejects an
absent key and accepts `null`; save always writes the key, as `null` when
unset. `x: T | null = null` keeps that save rule but loads an absent key as
`null` (for payloads written before the key existed). `x?: T | null` behaves
like `x?: T`.
- `null` is accepted only where the lowered contract explicitly permits it or
where the field type is an unknown/dictionary-shaped carrier. Otherwise it is
a load error.
Expand Down
6 changes: 3 additions & 3 deletions packages/typra-emitter/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ behavior is genuinely runtime-specific.
| --- | --- | --- |
| Model shapes | `features/model-shapes/main.tsp` | Required, optional, scalar-defaulted fields, typed records, unknown records, and named references. |
| Scalars | `features/scalars/main.tsp` | String, boolean, integer widths, numeric, float32, float64, and unknown values. |
| Collections | `features/collections/main.tsp` | Scalar arrays, model arrays, unknown maps, model maps, and `Record<T> \| Named<T>[]` dual-form collections. |
| Collections | `features/collections/main.tsp` | Scalar arrays, model arrays, unknown maps, string maps (including nullable defaults), model maps, and `Record<T> \| Named<T>[]` dual-form collections. |
| Polymorphism | `features/polymorphism/main.tsp` | Closed discriminated unions, abstract open bases, and named open discriminator bases. |
| Enums | `features/enums/main.tsp` | Closed string unions, open string unions, and parse-only aliases. |
| Enums | `features/enums/main.tsp` | Closed string unions, open string unions, parse-only aliases, and a union shared across model files. |
| Samples | `features/samples/main.tsp` | Inline `@sample` payloads and imported constant sample payloads with nested items. |
| Vectors | `features/vectors/main.tsp` | Inline `@vector` payloads, imported vector arrays, success expectations, and error expectations. |
| Coercions | `features/coercions/main.tsp` | Scalar-to-model coercions, factories, method stubs, and entry shorthand for named collections. |
Expand All @@ -38,7 +38,7 @@ behavior is genuinely runtime-specific.
| Dispatch | `features/dispatch/main.tsp` | Behavioral polymorphic dispatch via `@dispatch`, resolving a discriminator field to a deterministic parameter access path. |
| Provider wire | `features/wire/main.tsp` | Provider-specific names with `@@knownAs` and provider defaults with `@@defaultFor`. |
| Encoded names | `features/encoded-name/main.tsp` | `@encodedName("application/json", ...)` as the canonical dict/JSON/YAML key on load and save (scalar, nested model, collection, discriminator, inherited `@coerce` target, and `@@knownAs` interplay); non-JSON mimes are ignored. |
| Nullable fields | `features/nullable/main.tsp` | `x: T \| null` (required key, value may be null: load rejects an absent key and accepts `null`; save always writes the key, as `null` when unset) versus `x?: T` and `x?: T \| null` (both omitted when unset) across scalars, enums, models, arrays, and records. |
| Nullable fields | `features/nullable/main.tsp` | `x: T \| null` (required key, value may be null: load rejects an absent key and accepts `null`; save always writes the key, as `null` when unset) versus `x?: T` and `x?: T \| null` (both omitted when unset) across scalars, enums, models, arrays, and records. `x: T \| null = null` keeps the always-written save but loads an absent key as `null` (including inherited through a discriminated base). |
| Additional properties | `features/additional-properties/main.tsp` | Unknown-field passthrough (#336): `...Record<unknown>` and `is Record<unknown>` models keep every undeclared key in a generated `additionalProperties` member on load and write it back on save; known keys are matched by wire name (`@encodedName`), and a polymorphic base passes its passthrough to derived variants. A plain model stays closed. |
| Transport | `features/transport/main.tsp` | Path, query, header, cookie, body, status, success/error body envelopes, and wildcard status bodies. |
| Namespaces | `features/namespaces/main.tsp` | Nested namespaces and cross-namespace references. |
Expand Down
3 changes: 3 additions & 0 deletions packages/typra-emitter/fixtures/features/collections/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ model Root {
scalarList: string[];
objectList: CollectionItem[];
unknownMap: Record<unknown>;
@sample(#{ stringMap: #{ label: "kept", empty: "" } })
stringMap: Record<string>;
nullableStringMap: Record<string> | null = null;
objectMap: Record<CollectionItem>;
dualForm: Record<NamedCollectionItem> | Named<NamedCollectionItem>[];
}
Expand Down
4 changes: 3 additions & 1 deletion packages/typra-emitter/fixtures/features/enums/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ union MotionPlanKind {
@serializable
model MotionPlan {
kind: MotionPlanKind = MotionPlanKind.subtlePush;
}

@sample(#{ status: "ready" })
status?: Status;
}
23 changes: 23 additions & 0 deletions packages/typra-emitter/fixtures/features/nullable/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ namespace Typra.Fixtures.Features.Nullable;
// but its value may be null": load rejects an absent key yet accepts an explicit
// null, and save always writes the key (as null when unset). This is distinct from
// `x?: T`, which is omitted when unset. `x?: T | null` behaves like `x?: T`.
//
// `x: T | null = null` keeps the same save rule but lets load treat an absent key
// as null (lenient load, strict save), e.g. for legacy documents that omit it.
@serializable
model Root {
row: Row;
Expand All @@ -14,6 +17,16 @@ model Root {
// variant's own nullable field must both keep the required-nullable contract.
shape: Shape;
shapes: Shape[];

legacyOnly: LegacyOnly;
}

// Every nullable field has a `null` default, so load emits no missing-key guard at all
// (Go must then not import an unused `fmt`).
model LegacyOnly {
@sample(#{ legacyNote: null })
@sample(#{ legacyNote: "kept" })
legacyNote: string | null = null;
}

@discriminator("kind")
Expand All @@ -24,6 +37,9 @@ model Shape {
@sample(#{ label: null })
@sample(#{ label: "unit" })
label: string | null;

// Inherited required-nullable field with a `null` default: absent loads as null.
legacyLabel: string | null = null;
}

model Circle extends Shape {
Expand All @@ -42,6 +58,7 @@ model Square extends Shape {
}

alias Mood = "calm" | "busy";
alias Combo = "other" | Mood;

model Detail {
label: string;
Expand All @@ -54,11 +71,17 @@ model Row {

count: int32 | null;
mood: Mood | null;
combo?: Combo | null;
detail: Detail | null;
tags: string[] | null;
details: Detail[] | null;
labels: Record<string> | null;

// Required nullable with a `null` default: an absent key loads as null, and save
// still always writes the key.
legacy: string | null = null;
legacyDetail: Detail | null = null;

// Plain optional: omitted when unset.
note?: string;

Expand Down
47 changes: 46 additions & 1 deletion packages/typra-emitter/fixtures/integration/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ model FixtureRoot {
@doc("Dictionary-shaped metadata")
metadata?: Record<unknown>;

@sample(#{ typedRecords: #{ counts: #{ alpha: 1, beta: 2 }, owners: #{ primary: #{ id: "owner-typed-1", displayName: "Typed Owner" } } } })
@sample(#{ typedRecords: #{ counts: #{ alpha: 1, beta: 2 }, owners: #{ primary: #{ id: "owner-typed-1", displayName: "Typed Owner" } }, names: #{ label: "kept", empty: "" } } })
@doc("Records with scalar and model value types")
typedRecords: FixtureTypedRecords;

Expand Down Expand Up @@ -85,6 +85,48 @@ model FixtureRoot {
@sample(#{ passthroughEvents: #[#{ kind: "click", event_id: "event-1", x: 3 }] })
@doc("Polymorphic models whose base preserves undeclared fields (#336)")
passthroughEvents: FixturePassthroughEvent[];

// Shape samples are canonical; executable conformance separately feeds omitted keys.
@sample(#{ nullDefaults: #[#{ requiredText: null, legacy_text: null, legacyCount: null, legacyEnabled: null, legacyPayload: null, legacyState: null, legacyOwner: null, legacyTags: null, legacyOwners: null, legacyLabels: null, legacyNames: null, shapes: null }] })
@doc("Absent nullable defaults become explicit nulls on save; ordinary required keys remain required")
nullDefaults: FixtureNullDefaults[];
}

model FixtureNullDefaults {
requiredText: string | null;

@encodedName("application/json", "legacy_text")
legacyText: string | null = null;
legacyCount: uint32 | null = null;
legacyEnabled: boolean | null = null;
legacyPayload: unknown | null = null;
legacyState: FixtureNullDefaultState | null = null;
legacyOwner: FixtureOwner | null = null;
legacyTags: string[] | null = null;
legacyOwners: FixtureOwner[] | null = null;
legacyLabels: Record<unknown> | null = null;
legacyNames: Record<string> | null = null;
shapes: FixtureNullDefaultShape[] | null = null;
note?: string;

// Shares FixtureStatus with FixtureRoot, so a target whose files share one namespace
// (Go package, Swift module, Rust re-exports) must still declare the enum only once.
status?: FixtureStatus;
}

alias FixtureNullDefaultState = "ready" | "archived";

@discriminator("kind")
model FixtureNullDefaultShape {
kind: string;
label: string | null;
legacyLabel: string | null = null;
state?: FixtureNullDefaultState;
}

model FixtureNullDefaultCircle extends FixtureNullDefaultShape {
kind: "circle";
legacyRadius: uint32 | null = null;
}

@doc("""
Expand Down Expand Up @@ -122,6 +164,9 @@ model FixturePassthroughClick extends FixturePassthroughEvent {
@doc("Typed record values must preserve their declared value type.")
@serializable
model FixtureTypedRecords {
@sample(#{ names: #{ label: "kept", empty: "" } })
names: Record<string>;

@sample(#{ counts: #{ alpha: 1, beta: 2 } })
@doc("A record whose values are declared scalar integers")
counts: Record<int32>;
Expand Down
84 changes: 84 additions & 0 deletions packages/typra-emitter/scripts/fixtures/conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const fixtureRootSample = {
version: 1,
},
typedRecords: {
names: { label: "kept", empty: "" },
counts: {
alpha: 1,
beta: 2,
Expand Down Expand Up @@ -206,6 +207,62 @@ export const fixtureRootSample = {
hoverTarget: { id: "menu" },
},
],
nullDefaults: [
{ requiredText: null },
{
requiredText: null,
legacy_text: null,
legacyCount: null,
legacyEnabled: null,
legacyPayload: null,
legacyState: null,
legacyOwner: null,
legacyTags: null,
legacyOwners: null,
legacyLabels: null,
legacyNames: null,
shapes: null,
},
{
requiredText: "kept",
legacy_text: "kept",
legacyCount: 0,
legacyEnabled: false,
legacyPayload: { opaque: [1, null, { nested: true }] },
legacyState: "ready",
legacyOwner: { id: "legacy-owner", displayName: "Kept Owner" },
legacyTags: ["kept"],
legacyOwners: [{ id: "legacy-item", displayName: "Kept Item" }],
legacyLabels: { key: "kept" },
legacyNames: { key: "kept", empty: "" },
shapes: [{ kind: "circle", label: null, state: "archived" }],
note: "",
status: "complete",
},
{
requiredText: "",
legacy_text: "",
legacyCount: 4294967295,
legacyEnabled: true,
legacyPayload: [],
legacyState: "ready",
legacyOwner: null,
legacyTags: [],
legacyOwners: [],
legacyLabels: {},
legacyNames: {},
shapes: [{
kind: "circle",
label: "kept",
legacyLabel: "kept",
legacyRadius: 0,
}],
},
{
requiredText: null,
shapes: [{ kind: "future", label: null }],
},
],
};

// One canonical conformance input, embedded into every target program.
Expand Down Expand Up @@ -260,6 +317,33 @@ export const fixtureRootExpected = {
},
emptyForm: {},
},
// Only the input may omit these keys. Canonical saved output must contain them,
// including inherited defaults on both known and unknown discriminator values.
nullDefaults: fixtureRootSample.nullDefaults.map((entry) => ({
legacy_text: null,
legacyCount: null,
legacyEnabled: null,
legacyPayload: null,
legacyState: null,
legacyOwner: null,
legacyTags: null,
legacyOwners: null,
legacyLabels: null,
legacyNames: null,
shapes: null,
...entry,
// The shared FixtureStatus enum applies its parse alias here too.
...(entry.status === "complete" ? { status: "ready" } : {}),
...(entry.shapes
? {
shapes: entry.shapes.map((shape) => ({
legacyLabel: null,
...(shape.kind === "circle" ? { legacyRadius: null } : {}),
...shape,
})),
}
: {}),
})),
};

let defaultCorpusCoverageChecked = false;
Expand Down
2 changes: 2 additions & 0 deletions packages/typra-emitter/scripts/fixtures/targets/csharp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ export function runCSharpExecutableConformance() {
'if (reloadedUnknownRecords.RequiredValues["null"] is not null || reloadedUnknownRecords.OptionalValues?["null"] is not null) throw new InvalidOperationException("Record<unknown> null values must survive load/save/reload");',
"unknownRecords.OptionalValues = null;",
'if (unknownRecords.OptionalValues is not null) throw new InvalidOperationException("optional Record<unknown> must accept an absent dictionary");',
'try { FixtureTypedRecords.FromJson("""{"names":{"bad":null},"counts":{},"owners":{}}"""); throw new InvalidOperationException("Record<string> accepted a null entry"); } catch (ArgumentException error) { if (!error.Message.Contains("names.bad") || !error.Message.Contains("expected non-null string")) throw; }',
'try { FixtureNullDefaults.FromJson("""{"requiredText":null,"legacyNames":{"bad":null}}"""); throw new InvalidOperationException("nullable Record<string> accepted a null entry"); } catch (ArgumentException error) { if (!error.Message.Contains("legacyNames.bad") || !error.Message.Contains("expected non-null string")) throw; }',
'var wire = WireOptions.Load(new Dictionary<string, object?> { ["maxOutputTokens"] = 256, ["temperature"] = 0.7 });',
'var imageContent = FixtureContent.Load(new Dictionary<string, object?> { ["kind"] = "image", ["url"] = "https://example.com/fixture.png" });',
'var knownContent = FixtureContent.Load(new Dictionary<string, object?> { ["kind"] = "text", ["value"] = "hello" }).Save();',
Expand Down
Loading
Loading