diff --git a/packages/bundle-size/README.md b/packages/bundle-size/README.md
index ef14b8c68..6da81e8d2 100644
--- a/packages/bundle-size/README.md
+++ b/packages/bundle-size/README.md
@@ -16,11 +16,11 @@ usually do. We repeat this for an increasing number of files.
| code generator | files | bundle size | minified | compressed |
| ------------------- | ----: | ----------: | --------: | ---------: |
-| Protobuf-ES | 1 | 131,530 b | 68,233 b | 15,681 b |
-| Protobuf-ES | 4 | 133,719 b | 69,743 b | 16,342 b |
-| Protobuf-ES | 8 | 136,481 b | 71,514 b | 16,880 b |
-| Protobuf-ES | 16 | 146,931 b | 79,495 b | 19,248 b |
-| Protobuf-ES | 32 | 174,722 b | 101,511 b | 24,714 b |
+| Protobuf-ES | 1 | 132,069 b | 68,479 b | 15,746 b |
+| Protobuf-ES | 4 | 134,258 b | 69,989 b | 16,445 b |
+| Protobuf-ES | 8 | 137,020 b | 71,760 b | 16,938 b |
+| Protobuf-ES | 16 | 147,470 b | 79,741 b | 19,274 b |
+| Protobuf-ES | 32 | 175,261 b | 101,759 b | 24,722 b |
| protobuf-javascript | 1 | 104,048 b | 70,320 b | 15,540 b |
| protobuf-javascript | 4 | 130,537 b | 85,672 b | 16,956 b |
| protobuf-javascript | 8 | 152,429 b | 98,044 b | 18,138 b |
diff --git a/packages/bundle-size/chart.svg b/packages/bundle-size/chart.svg
index d0f92a29b..711b3fa01 100644
--- a/packages/bundle-size/chart.svg
+++ b/packages/bundle-size/chart.svg
@@ -43,14 +43,14 @@
0 KiB
-
+
Protobuf-ES
-Protobuf-ES 15.31 KiB for 1 files
-Protobuf-ES 15.96 KiB for 4 files
-Protobuf-ES 16.48 KiB for 8 files
-Protobuf-ES 18.8 KiB for 16 files
-Protobuf-ES 24.13 KiB for 32 files
+Protobuf-ES 15.38 KiB for 1 files
+Protobuf-ES 16.06 KiB for 4 files
+Protobuf-ES 16.54 KiB for 8 files
+Protobuf-ES 18.82 KiB for 16 files
+Protobuf-ES 24.14 KiB for 32 files
diff --git a/packages/protobuf-test/src/enum-open-closed.test.ts b/packages/protobuf-test/src/enum-open-closed.test.ts
new file mode 100644
index 000000000..08a5f1e34
--- /dev/null
+++ b/packages/protobuf-test/src/enum-open-closed.test.ts
@@ -0,0 +1,73 @@
+// Copyright 2021-2025 Buf Technologies, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { describe, expect, test } from "@jest/globals";
+import { BinaryWriter, WireType } from "@bufbuild/protobuf/wire";
+import { fromBinary, isFieldSet } from "@bufbuild/protobuf";
+import * as proto3_ts from "./gen/ts/extra/proto3_pb.js";
+import * as proto2_ts from "./gen/ts/extra/proto2_pb.js";
+
+describe("open enum", () => {
+ test("from binary sets foreign value", () => {
+ expect(proto3_ts.Proto3EnumSchema.open).toBe(true);
+ const foreignValue = 4;
+ expect(proto3_ts.Proto3Enum[foreignValue]).toBeUndefined();
+ const bytes = new BinaryWriter()
+ .tag(
+ proto3_ts.Proto3MessageSchema.field.singularEnumField.number,
+ WireType.Varint,
+ )
+ .int32(foreignValue)
+ .finish();
+ const msg = fromBinary(proto3_ts.Proto3MessageSchema, bytes);
+ const set = isFieldSet(
+ msg,
+ proto3_ts.Proto3MessageSchema.field.singularEnumField,
+ );
+ expect(set).toBe(true);
+ expect(msg.singularEnumField).toBe(foreignValue);
+ expect(msg.$unknown).toBeUndefined();
+ });
+});
+
+describe("closed enum", () => {
+ test("from binary sets foreign value as unknown field", () => {
+ expect(proto2_ts.Proto2EnumSchema.open).toBe(false);
+ const foreignValue = 4;
+ expect(proto2_ts.Proto2Enum[foreignValue]).toBeUndefined();
+ const bytes = new BinaryWriter()
+ .tag(
+ proto2_ts.Proto2MessageSchema.field.optionalEnumField.number,
+ WireType.Varint,
+ )
+ .int32(foreignValue)
+ .finish();
+ const msg = fromBinary(proto2_ts.Proto2MessageSchema, bytes);
+ const set = isFieldSet(
+ msg,
+ proto2_ts.Proto2MessageSchema.field.optionalEnumField,
+ );
+ expect(set).toBe(false);
+ expect(msg.optionalEnumField).toBe(proto2_ts.Proto2Enum.YES);
+ expect(msg.$unknown).toBeDefined();
+ expect(msg.$unknown?.length).toBe(1);
+ expect(msg.$unknown?.[0].no).toBe(
+ proto2_ts.Proto2MessageSchema.field.optionalEnumField.number,
+ );
+ expect(msg.$unknown?.[0].wireType).toBe(WireType.Varint);
+ expect(msg.$unknown?.[0].data).toStrictEqual(
+ new BinaryWriter().int32(foreignValue).finish(),
+ );
+ });
+});
diff --git a/packages/protobuf/src/from-binary.ts b/packages/protobuf/src/from-binary.ts
index d5bd7e491..47ee627ab 100644
--- a/packages/protobuf/src/from-binary.ts
+++ b/packages/protobuf/src/from-binary.ts
@@ -22,7 +22,11 @@ import type {
import { scalarZeroValue } from "./reflect/scalar.js";
import type { ScalarValue } from "./reflect/scalar.js";
import { reflect } from "./reflect/reflect.js";
-import { BinaryReader, WireType } from "./wire/binary-encoding.js";
+import {
+ BinaryReader,
+ BinaryWriter,
+ WireType,
+} from "./wire/binary-encoding.js";
/**
* Options for parsing binary data.
@@ -151,7 +155,20 @@ export function readField(
message.set(field, readScalar(reader, field.scalar));
break;
case "enum":
- message.set(field, readScalar(reader, ScalarType.INT32) as number);
+ const val = readScalar(reader, ScalarType.INT32);
+ if (field.enum.open) {
+ message.set(field, val);
+ } else {
+ const ok = field.enum.values.some((v) => v.number === val);
+ if (ok) {
+ message.set(field, val);
+ } else if (options.readUnknownFields) {
+ const data = new BinaryWriter().int32(val as number).finish();
+ const unknownFields = message.getUnknown() ?? [];
+ unknownFields.push({ no: field.number, wireType, data });
+ message.setUnknown(unknownFields);
+ }
+ }
break;
case "message":
message.set(