diff --git a/.chronus/changes/protobuf-optional-2026-4-5-12-8-31.md b/.chronus/changes/protobuf-optional-2026-4-5-12-8-31.md new file mode 100644 index 00000000000..55d9222c337 --- /dev/null +++ b/.chronus/changes/protobuf-optional-2026-4-5-12-8-31.md @@ -0,0 +1,11 @@ +--- +changeKind: feature +packages: + - "@typespec/protobuf" +--- + +Map TypeSpec optionality (`?`) to protobuf `optional` where appropriate. + +- `optional` is applied to fields with protobuf scalar types to set explicit presence. +- `optional` is _not_ applied to fields with message types, because they _always_ have explicit presence. +- Attempting to convert a TypeSpec optional property where the type is an array or `Protobuf.Map` instance produces a warning, because protobuf cannot differentiate between "empty" and "unset" `repeated`/`map`-typed fields. diff --git a/packages/protobuf/src/ast.ts b/packages/protobuf/src/ast.ts index 068a62de8e8..c397a7d2c0e 100644 --- a/packages/protobuf/src/ast.ts +++ b/packages/protobuf/src/ast.ts @@ -262,6 +262,10 @@ export interface ProtoFieldDeclaration extends ProtoDeclarationCommon { * Whether or not the field is repeated (i.e. an array). */ repeated?: boolean; + /** + * Whether or not the field uses the proto3 `optional` label. + */ + optional?: boolean; options?: Partial; type: ProtoType; index: number; diff --git a/packages/protobuf/src/lib.ts b/packages/protobuf/src/lib.ts index 611140ab6aa..e46c8d8c4e0 100644 --- a/packages/protobuf/src/lib.ts +++ b/packages/protobuf/src/lib.ts @@ -112,6 +112,20 @@ export const TypeSpecProtobufLibrary = createTypeSpecLibrary({ union: "a message field's type may not be a union", }, }, + "optional-array-field": { + severity: "warning", + messages: { + default: + "optional array fields cannot preserve unset versus empty in protobuf; emitting a repeated field without the 'optional' label", + }, + }, + "optional-map-field": { + severity: "warning", + messages: { + default: + "optional map fields cannot preserve unset versus empty in protobuf; emitting a map field without the 'optional' label", + }, + }, "namespace-collision": { severity: "error", messages: { diff --git a/packages/protobuf/src/transform/index.ts b/packages/protobuf/src/transform/index.ts index 9c8402f0383..50522ba4d57 100644 --- a/packages/protobuf/src/transform/index.ts +++ b/packages/protobuf/src/transform/index.ts @@ -832,10 +832,47 @@ function tspToProto(program: Program, emitterOptions: ProtobufEmitterOptions): P // Determine if the property type is an array if (isArray(property.type)) field.repeated = true; + field.optional = shouldEmitOptionalLabel(property); return field; } + function shouldEmitOptionalLabel(property: ModelProperty): boolean { + if (!property.optional) { + return false; + } + + if (isArray(property.type)) { + reportDiagnostic.once(program, { + code: "optional-array-field", + format: {}, + target: property, + }); + return false; + } + + if (isMap(program, property.type)) { + reportDiagnostic.once(program, { + code: "optional-map-field", + format: {}, + target: property, + }); + return false; + } + + switch (property.type.kind) { + case "Scalar": + case "Enum": + return true; + case "Intrinsic": + case "Model": + case "Union": + return false; + default: + return false; + } + } + /** * @param e - the Enum to convert * @returns a corresponding protobuf enum declaration diff --git a/packages/protobuf/src/write.ts b/packages/protobuf/src/write.ts index 3e58853d057..c08dc69ba82 100644 --- a/packages/protobuf/src/write.ts +++ b/packages/protobuf/src/write.ts @@ -178,7 +178,7 @@ function writeVariant(decl: ProtoEnumVariantDeclaration, indentLevel: number): I } function writeField(decl: ProtoFieldDeclaration, indentLevel: number): Iterable { - const prefix = decl.repeated ? "repeated " : ""; + const prefix = decl.repeated ? "repeated " : decl.optional ? "optional " : ""; const output = prefix + `${writeType(decl.type)} ${decl.name} = ${decl.index};`; return writeDocumentationCommentFlexible(decl, output, indentLevel); diff --git a/packages/protobuf/test/scenarios/optional-lossy/diagnostics.txt b/packages/protobuf/test/scenarios/optional-lossy/diagnostics.txt new file mode 100644 index 00000000000..ffe6f894d71 --- /dev/null +++ b/packages/protobuf/test/scenarios/optional-lossy/diagnostics.txt @@ -0,0 +1,2 @@ +/test/main.tsp:18:13 - warning @typespec/protobuf/optional-array-field: optional array fields cannot preserve unset versus empty in protobuf; emitting a repeated field without the 'optional' label +/test/main.tsp:19:13 - warning @typespec/protobuf/optional-map-field: optional map fields cannot preserve unset versus empty in protobuf; emitting a map field without the 'optional' label diff --git a/packages/protobuf/test/scenarios/optional-lossy/input/main.tsp b/packages/protobuf/test/scenarios/optional-lossy/input/main.tsp new file mode 100644 index 00000000000..8a2bb34ca75 --- /dev/null +++ b/packages/protobuf/test/scenarios/optional-lossy/input/main.tsp @@ -0,0 +1,20 @@ +import "@typespec/protobuf"; + +using Protobuf; + +@package +namespace Test; + +@Protobuf.service +interface Service { + foo(...Input): Output; +} + +model Input { + @field(1) id: string; +} + +model Output { + @field(1) tags?: string[]; + @field(2) counters?: Map; +} diff --git a/packages/protobuf/test/scenarios/optional-lossy/output/@typespec/protobuf/main.proto b/packages/protobuf/test/scenarios/optional-lossy/output/@typespec/protobuf/main.proto new file mode 100644 index 00000000000..b1ca54f0ad1 --- /dev/null +++ b/packages/protobuf/test/scenarios/optional-lossy/output/@typespec/protobuf/main.proto @@ -0,0 +1,16 @@ +// Generated by Microsoft TypeSpec + +syntax = "proto3"; + +message Input { + string id = 1; +} + +message Output { + repeated string tags = 1; + map counters = 2; +} + +service Service { + rpc Foo(Input) returns (Output); +} diff --git a/packages/protobuf/test/scenarios/optional/input/main.tsp b/packages/protobuf/test/scenarios/optional/input/main.tsp new file mode 100644 index 00000000000..a78e33d04ab --- /dev/null +++ b/packages/protobuf/test/scenarios/optional/input/main.tsp @@ -0,0 +1,31 @@ +import "@typespec/protobuf"; + +using Protobuf; + +@package +namespace Test; + +@Protobuf.service +interface Service { + foo(...Input): Output; +} + +enum Status { + Unknown: 0, + Ready: 1, +} + +model Input { + @field(1) id: string; +} + +model Details { + @field(1) note: string; +} + +model Output { + @field(1) count?: int32; + @field(2) name?: string; + @field(3) status?: Status; + @field(4) details?: Details; +} diff --git a/packages/protobuf/test/scenarios/optional/output/@typespec/protobuf/main.proto b/packages/protobuf/test/scenarios/optional/output/@typespec/protobuf/main.proto new file mode 100644 index 00000000000..566b5b8227e --- /dev/null +++ b/packages/protobuf/test/scenarios/optional/output/@typespec/protobuf/main.proto @@ -0,0 +1,27 @@ +// Generated by Microsoft TypeSpec + +syntax = "proto3"; + +message Input { + string id = 1; +} + +enum Status { + Unknown = 0; + Ready = 1; +} + +message Details { + string note = 1; +} + +message Output { + optional int32 count = 1; + optional string name = 2; + optional Status status = 3; + Details details = 4; +} + +service Service { + rpc Foo(Input) returns (Output); +} diff --git a/website/src/content/docs/docs/emitters/protobuf/guide.md b/website/src/content/docs/docs/emitters/protobuf/guide.md index 7619db4efc1..987742f224f 100644 --- a/website/src/content/docs/docs/emitters/protobuf/guide.md +++ b/website/src/content/docs/docs/emitters/protobuf/guide.md @@ -82,6 +82,31 @@ message TestMessage { } ``` +#### Optional fields + +When a TypeSpec property is defined as optional (`?`) inside of a model that defines a `message`, the protobuf emitter writes the proto3 `optional` label for singular scalar and enum fields. + +```typespec +model Example { + @field(1) + count?: int32; + + @field(2) + name?: string; +} +``` + +```protobuf +message Example { + optional int32 count = 1; + optional string name = 2; +} +``` + +Optional message fields (TypeSpec optional properties where the type is a model) are emitted without the `optional` label because message-typed fields always have explicit presence discipline in proto3 (in other words, _all message-typed fields are effectively optional in the Protocol Buffers layer_). Validating optionality constraints on message-typed fields requires checking the presence of the field in the application that uses the protobuf implementation. + +Optional `repeated` and `map` fields are emitted without `optional` and produce a warning; protobuf cannot distinguish between _unset_ and _empty_ for those shapes because they are represented by "repeating" a field index within the protobuf message payload (if the field isn't set in the message, that means "_empty_" and there is no alternative to represent "_this field is not set_"). + ### Services TypeSpec defines a "service" using the [`TypeSpec.service` decorator][native-service], but the Protobuf "service" concept is different and is denoted by the [`TypeSpec.Protobuf.service` decorator][protobuf-service].