Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add experimental explicit optionality overrides for derived properties. Overrides retain same-value transform intent and inherited annotation provenance through compiler and typekit cloning.

```ts
import { unsafe_overridePropertyOptionality } from "@typespec/compiler/experimental";

// In a decorator, pass its context so cloning does not repeat the transform.
unsafe_overridePropertyOptionality(derivedProperty, true, context);
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/versioning"
---

Keep OptionalProperties properties optional in every version, including spreads and properties already made optional. Honor explicit structural overrides instead of inherited @madeRequired and @madeOptional history, while preserving presence, rename, and type history and validating newly authored annotations.
48 changes: 48 additions & 0 deletions packages/compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,54 @@
This package implements the core of the [TypeSpec](https://github.com/microsoft/typespec)
compiler and its command-line interface.

## Experimental optionality overrides (prototype)

Transforms can explicitly replace a derived property's inherited optionality:

```ts
import type { DecoratorContext, Model } from "@typespec/compiler";
import { unsafe_overridePropertyOptionality } from "@typespec/compiler/experimental";

export function $relaxed(context: DecoratorContext, target: Model) {
for (const property of target.properties.values()) {
unsafe_overridePropertyOptionality(property, true, context);
}
}
```

`true` makes the property optional; `false` makes it required. Only mutate
properties owned by the transform, typically copied using a spread, `model is`,
the checker, or typekit. The source and sibling copies are not changed.

- Calling the API records intent even if `property.optional` already has that
value. `@withOptionalProperties` (and therefore `OptionalProperties<T>`) uses
this contract.
- Decorators must pass their context. Compiler/typekit clones preserve the
override and its decorator provenance. Replaying an already-applied transform
does not overwrite a later explicit transform or a version snapshot.
Non-decorator transforms omit the context; the latest explicit override wins.
- Only optionality is replaced. Property presence, names, types, documentation,
and unrelated metadata are unchanged.
- Libraries owning optionality metadata can store its `DecoratorContext` and
consult `unsafe_getPropertyOptionalityOverride(property)?.supersedes(context)`.
This distinguishes inherited annotations from newly authored annotations and
augments on the transformed copy. It also works when state was recorded before
the transform, without deleting decorators or unrelated library state.
- Versioning uses this contract for both inherited `@madeRequired` and
`@madeOptional`. Newly authored history still applies and is still validated;
invalid annotations on the original property still diagnose.

Custom optionality transforms must adopt this API to receive these semantics.
Arbitrary `property.optional = value` assignments cannot express same-value
intent. Direct assignments remain appropriate when **realizing** a version
snapshot: they do not establish a new semantic override.

This is an experimental, optionality-only prototype, not a general transform or
decorator replay framework. It does not add support for augment targets that
the name resolver cannot statically bind (for example, properties introduced
through template-parameter spreads). Use statically resolvable copies when
adding new augment metadata.

## See also

- [TypeSpec Getting Started](https://github.com/microsoft/typespec#getting-started)
Expand Down
20 changes: 20 additions & 0 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ import {
visitChildren,
} from "./parser.js";
import type { Program } from "./program.js";
import {
copyPropertyOptionality,
getPropertyOptionalityOverride,
registerOptionalityDecoratorContext,
} from "./property-optionality.js";
import { createTypeRelationChecker } from "./type-relation-checker.js";
import {
getFullyQualifiedSymbolName,
Expand Down Expand Up @@ -8358,10 +8363,21 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
stats.finishedTypes++;

if (!options.skipDecorators) {
const optionality =
typeDef.kind === "ModelProperty" && getPropertyOptionalityOverride(typeDef);
const optional = typeDef.kind === "ModelProperty" && typeDef.optional;
let postSelfValidators: ValidatorFn[] = [];
if ("decorators" in typeDef) {
postSelfValidators = applyDecoratorsToType(typeDef);
}
// Replay must not undo a transform or a snapshot realization. A new
// explicit override made during replay still takes precedence.
if (typeDef.kind === "ModelProperty") {
const current = getPropertyOptionalityOverride(typeDef);
if (current) {
typeDef.optional = current === optionality ? optional : current.optional;
}
}
typeDef.isFinished = true;
Object.setPrototypeOf(typeDef, typePrototype);
runPostValidators(postSelfValidators);
Expand Down Expand Up @@ -8533,6 +8549,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
break;
}

if (type.kind === "ModelProperty" && clone.kind === "ModelProperty") {
copyPropertyOptionality(type, clone);
}
return clone as T;
}

Expand Down Expand Up @@ -9181,6 +9200,7 @@ function createDecoratorContext(program: Program, decApp: DecoratorApplication):
},
};

registerOptionalityDecoratorContext(decApp, decCtx, passthrough.decorator);
return decCtx;
}

Expand Down
144 changes: 144 additions & 0 deletions packages/compiler/src/core/property-optionality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { DecoratorApplication, DecoratorContext, ModelProperty } from "./types.js";

/**
* An explicit replacement of a property's inherited optionality.
*
* This experimental contract covers optionality only, not presence, name, type,
* or other metadata. Libraries owning optionality metadata can retain its
* decorator context and use `supersedes` when reading that metadata.
*
* @experimental
*/
export interface PropertyOptionalityOverride {
/** The semantic optionality chosen by the transform, not a version snapshot. */
readonly optional: boolean;

/**
* Whether this override supersedes metadata from this decorator application.
* Only applications inherited before the transform are superseded. Annotations
* authored on the transformed copy remain applicable, including new augments.
*/
supersedes(context: DecoratorContext): boolean;
}

interface OverrideState {
readonly value: PropertyOptionalityOverride;
readonly superseded: ReadonlySet<object>;
readonly transforms: ReadonlyMap<object, object>;
}

interface ApplicationContext {
readonly origin: object;
readonly execution: object;
}

const stateKey = Symbol.for("TypeSpec.PropertyOptionality");
interface OptionalityState {
overrides: WeakMap<ModelProperty, OverrideState>;
inheritedApplications: WeakMap<ModelProperty, ReadonlySet<object>>;
applicationOrigins: WeakMap<DecoratorApplication, object>;
contextOrigins: WeakMap<DecoratorContext, ApplicationContext>;
}
// Like Realm, compiler and typekit instances can cross module boundaries.
const { overrides, inheritedApplications, applicationOrigins, contextOrigins } = ((
globalThis as typeof globalThis & { [stateKey]?: OptionalityState }
)[stateKey] ??= {
overrides: new WeakMap(),
inheritedApplications: new WeakMap(),
applicationOrigins: new WeakMap(),
contextOrigins: new WeakMap(),
});

function applicationOrigin(application: DecoratorApplication): object {
let origin = applicationOrigins.get(application);
if (!origin) {
origin = {};
applicationOrigins.set(application, origin);
}
return origin;
}

/** @internal */
export function copyOptionalityDecoratorOrigin(
source: DecoratorApplication,
clone: DecoratorApplication,
): void {
applicationOrigins.set(clone, applicationOrigin(source));
}

/** @internal */
export function registerOptionalityDecoratorContext(
application: DecoratorApplication,
...contexts: DecoratorContext[]
): void {
const value = { origin: applicationOrigin(application), execution: {} };
for (const context of contexts) contextOrigins.set(context, value);
}

/** @internal */
export function copyPropertyOptionality(source: ModelProperty, clone: ModelProperty): void {
if (source.decorators.length > 0) {
inheritedApplications.set(clone, new Set(source.decorators.map(applicationOrigin)));
}
const override = overrides.get(source);
if (override) overrides.set(clone, override);
}

/**
* Replace a property's inherited optionality, even when its boolean value does
* not change. Call on an owned derived property, never a shared source.
*
* Decorators must pass their context so replay does not repeat a semantic
* transform or overwrite a later transform/version realization. Non-decorator
* transforms omit it. A subsequent explicit override wins.
*
* Ordinary `property.optional = value` assignments do not record intent and
* remain appropriate for realizing version snapshots.
*
* @experimental
*/
export function overridePropertyOptionality(
property: ModelProperty,
optional: boolean,
context?: DecoratorContext,
): void {
const previous = overrides.get(property);
const application = context && contextOrigins.get(context);
const origin = application?.origin ?? context;
const execution = application?.execution ?? context;
if (origin && previous?.transforms.has(origin) && previous.transforms.get(origin) !== execution) {
return;
}

const superseded = new Set([
...(previous?.superseded ?? []),
...(inheritedApplications.get(property) ?? []),
]);
const transforms = new Map(previous?.transforms);
if (origin && execution) transforms.set(origin, execution);

overrides.set(property, {
value: Object.freeze({
optional,
supersedes: (context: DecoratorContext) => {
const origin = contextOrigins.get(context)?.origin;
return origin !== undefined && superseded.has(origin);
},
}),
superseded,
transforms,
});
property.optional = optional;
}

/**
* Get the explicit semantic override, if any. Compiler and typekit cloning
* preserve it and its annotation provenance without modifying the source.
*
* @experimental
*/
export function getPropertyOptionalityOverride(
property: ModelProperty,
): PropertyOptionalityOverride | undefined {
return overrides.get(property)?.value;
}
5 changes: 5 additions & 0 deletions packages/compiler/src/experimental/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export {
getPropertyOptionalityOverride as unsafe_getPropertyOptionalityOverride,
overridePropertyOptionality as unsafe_overridePropertyOptionality,
type PropertyOptionalityOverride as unsafe_PropertyOptionalityOverride,
} from "../core/property-optionality.js";
export { createSourceLoader as unsafe_createSourceLoader } from "../core/source-loader.js";
export { useCache as unsafe_useCache } from "./cache.js";
export {
Expand Down
5 changes: 4 additions & 1 deletion packages/compiler/src/experimental/mutators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { compilerAssert } from "../core/diagnostics.js";
import { getLocationContext } from "../core/helpers/location-context.js";
import { isNumeric } from "../core/numeric.js";
import type { Program } from "../core/program.js";
import { copyOptionalityDecoratorOrigin } from "../core/property-optionality.js";
import { isTemplateInstance, isType, isValue } from "../core/type-utils.js";
import type {
DecoratedType,
Expand Down Expand Up @@ -764,7 +765,9 @@ function createMutatorEngine(
}

if (mutating) {
type.decorators[index] = { ...dec, args };
const clone = { ...dec, args };
copyOptionalityDecoratorOrigin(dec, clone);
type.decorators[index] = clone;
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/compiler/src/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { parseMimeType } from "../core/mime-type.js";
import type { Numeric } from "../core/numeric.js";
import { isNumeric } from "../core/numeric.js";
import type { Program } from "../core/program.js";
import { overridePropertyOptionality } from "../core/property-optionality.js";
import { isArrayModelType, isValue } from "../core/type-utils.js";
import type {
AugmentDecoratorStatementNode,
Expand Down Expand Up @@ -1011,7 +1012,7 @@ export const $withOptionalProperties: WithOptionalPropertiesDecorator = (
target: Model,
) => {
// Make all properties of the target type optional
target.properties.forEach((p) => (p.optional = true));
target.properties.forEach((p) => overridePropertyOptionality(p, true, context));
};

// -- @withoutOmittedProperties decorator ----------------------
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler/src/typekit/kits/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getMinValue,
getMinValueExclusive,
} from "../../core/intrinsic-type-state.js";
import { copyPropertyOptionality } from "../../core/property-optionality.js";
import { isNeverType } from "../../core/type-utils.js";
import type {
Entity,
Expand Down Expand Up @@ -262,6 +263,9 @@ defineKit<TypekitExtension>({
});
break;
}
if (type.kind === "ModelProperty" && clone.kind === "ModelProperty") {
copyPropertyOptionality(type, clone);
}
this.realm.addType(clone);
return clone;
},
Expand Down
Loading
Loading