Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .chronus/changes/protobuf-optional-2026-4-5-12-8-31.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/protobuf/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DefaultFieldOptions>;
type: ProtoType;
index: number;
Expand Down
14 changes: 14 additions & 0 deletions packages/protobuf/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
37 changes: 37 additions & 0 deletions packages/protobuf/src/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/protobuf/src/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ function writeVariant(decl: ProtoEnumVariantDeclaration, indentLevel: number): I
}

function writeField(decl: ProtoFieldDeclaration, indentLevel: number): Iterable<string> {
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions packages/protobuf/test/scenarios/optional-lossy/input/main.tsp
Original file line number Diff line number Diff line change
@@ -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<sfixed32, string>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Generated by Microsoft TypeSpec

syntax = "proto3";

message Input {
string id = 1;
}

message Output {
repeated string tags = 1;
map<sfixed32, string> counters = 2;
}

service Service {
rpc Foo(Input) returns (Output);
}
31 changes: 31 additions & 0 deletions packages/protobuf/test/scenarios/optional/input/main.tsp
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
25 changes: 25 additions & 0 deletions website/src/content/docs/docs/emitters/protobuf/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
Loading