Repository: stephenh/ts-proto
Pull request #1163 (2025-02-20 17:00:59)

fix: types export path
The build path was updated [here](https://github.com/stephenh/ts-proto/commit/3ecd4986063952ae06c6136fbd9ae0cfc75212d8), but the path for the types was not. This fixes the issue so you can import these types again.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48522580..bda584cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+## [2.7.1](https://github.com/stephenh/ts-proto/compare/v2.7.0...v2.7.1) (2025-05-30)
+
+
+### Bug Fixes
+
+* Bump ts-poet for dts imports. ([#1189](https://github.com/stephenh/ts-proto/issues/1189)) ([0b6400f](https://github.com/stephenh/ts-proto/commit/0b6400f9da74a0514af56eda397e0b5573d85e27)), closes [#1184](https://github.com/stephenh/ts-proto/issues/1184)
+
+# [2.7.0](https://github.com/stephenh/ts-proto/compare/v2.6.1...v2.7.0) (2025-03-25)
+
+
+### Features
+
+* Support string form of duration ([#1168](https://github.com/stephenh/ts-proto/issues/1168)) ([8308893](https://github.com/stephenh/ts-proto/commit/83088930554319c7f35805c25295520918b7b09f))
+
 ## [2.6.1](https://github.com/stephenh/ts-proto/compare/v2.6.0...v2.6.1) (2024-12-27)
 
 
diff --git a/README.markdown b/README.markdown
index 616f9a27..cbd36ce2 100644
--- a/README.markdown
+++ b/README.markdown
@@ -645,6 +645,7 @@ export interface User {
 
 This option allows the library to act in a compatible way with the [Wire implementation](https://square.github.io/wire/) maintained and used by Square/Block. Note: this option should only be used in combination with other client/server code generated using Wire or ts-proto with this option enabled.
 
+- With `--ts_proto_opt=useDuration=string`, the generated code will use the `string` type for `google.protobuf.Duration` instead of `{seconds: number, nano: number}`.
 
 ### NestJS Support
 
@@ -794,7 +795,6 @@ ts-protoc --ts_proto_out=./output -I=./protos ./protoc/*.proto
 
 # Todo
 
-- Support the string-based encoding of duration in `fromJSON`/`toJSON`
 - Make `oneof=unions-value` the default behavior in 2.0
 - Probably change `forceLong` default in 2.0, should default to `forceLong=long`
 - Make `esModuleInterop=true` the default in 2.0
diff --git a/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts b/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts
index 70703a07..93c4d440 100644
--- a/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts
+++ b/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts
@@ -3,14 +3,17 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
-import { ChannelCredentials, Client, makeGenericClientConstructor, Metadata } from "@grpc/grpc-js";
-import type {
-  CallOptions,
-  ClientOptions,
-  ClientUnaryCall,
-  handleUnaryCall,
-  ServiceError,
-  UntypedServiceImplementation,
+import {
+  type CallOptions,
+  ChannelCredentials,
+  Client,
+  type ClientOptions,
+  type ClientUnaryCall,
+  type handleUnaryCall,
+  makeGenericClientConstructor,
+  Metadata,
+  type ServiceError,
+  type UntypedServiceImplementation,
 } from "@grpc/grpc-js";
 import { Timestamp } from "./google/protobuf/timestamp";
 
diff --git a/integration/grpc-js-use-date-string-nano/grpc-js-use-date-string-nano.ts b/integration/grpc-js-use-date-string-nano/grpc-js-use-date-string-nano.ts
index f031d495..4a924c25 100644
--- a/integration/grpc-js-use-date-string-nano/grpc-js-use-date-string-nano.ts
+++ b/integration/grpc-js-use-date-string-nano/grpc-js-use-date-string-nano.ts
@@ -3,14 +3,17 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
-import { ChannelCredentials, Client, makeGenericClientConstructor, Metadata } from "@grpc/grpc-js";
-import type {
-  CallOptions,
-  ClientOptions,
-  ClientUnaryCall,
-  handleUnaryCall,
-  ServiceError,
-  UntypedServiceImplementation,
+import {
+  type CallOptions,
+  ChannelCredentials,
+  Client,
+  type ClientOptions,
+  type ClientUnaryCall,
+  type handleUnaryCall,
+  makeGenericClientConstructor,
+  Metadata,
+  type ServiceError,
+  type UntypedServiceImplementation,
 } from "@grpc/grpc-js";
 import NanoDate from "nano-date";
 import { Timestamp } from "./google/protobuf/timestamp";
diff --git a/integration/grpc-js-use-date-string/grpc-js-use-date-string.ts b/integration/grpc-js-use-date-string/grpc-js-use-date-string.ts
index 7ba9f5e3..74d98af1 100644
--- a/integration/grpc-js-use-date-string/grpc-js-use-date-string.ts
+++ b/integration/grpc-js-use-date-string/grpc-js-use-date-string.ts
@@ -3,14 +3,17 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
-import { ChannelCredentials, Client, makeGenericClientConstructor, Metadata } from "@grpc/grpc-js";
-import type {
-  CallOptions,
-  ClientOptions,
-  ClientUnaryCall,
-  handleUnaryCall,
-  ServiceError,
-  UntypedServiceImplementation,
+import {
+  type CallOptions,
+  ChannelCredentials,
+  Client,
+  type ClientOptions,
+  type ClientUnaryCall,
+  type handleUnaryCall,
+  makeGenericClientConstructor,
+  Metadata,
+  type ServiceError,
+  type UntypedServiceImplementation,
 } from "@grpc/grpc-js";
 import { Timestamp } from "./google/protobuf/timestamp";
 
diff --git a/integration/grpc-js-use-date-true/grpc-js-use-date-true.ts b/integration/grpc-js-use-date-true/grpc-js-use-date-true.ts
index a50b2a10..45373dba 100644
--- a/integration/grpc-js-use-date-true/grpc-js-use-date-true.ts
+++ b/integration/grpc-js-use-date-true/grpc-js-use-date-true.ts
@@ -3,14 +3,17 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
-import { ChannelCredentials, Client, makeGenericClientConstructor, Metadata } from "@grpc/grpc-js";
-import type {
-  CallOptions,
-  ClientOptions,
-  ClientUnaryCall,
-  handleUnaryCall,
-  ServiceError,
-  UntypedServiceImplementation,
+import {
+  type CallOptions,
+  ChannelCredentials,
+  Client,
+  type ClientOptions,
+  type ClientUnaryCall,
+  type handleUnaryCall,
+  makeGenericClientConstructor,
+  Metadata,
+  type ServiceError,
+  type UntypedServiceImplementation,
 } from "@grpc/grpc-js";
 import { Timestamp } from "./google/protobuf/timestamp";
 
diff --git a/integration/grpc-js/simple.ts b/integration/grpc-js/simple.ts
index cecb9124..b58990e4 100644
--- a/integration/grpc-js/simple.ts
+++ b/integration/grpc-js/simple.ts
@@ -3,20 +3,23 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
-import { ChannelCredentials, Client, makeGenericClientConstructor, Metadata } from "@grpc/grpc-js";
-import type {
-  CallOptions,
-  ClientDuplexStream,
-  ClientOptions,
-  ClientReadableStream,
-  ClientUnaryCall,
-  ClientWritableStream,
-  handleBidiStreamingCall,
-  handleClientStreamingCall,
-  handleServerStreamingCall,
-  handleUnaryCall,
-  ServiceError,
-  UntypedServiceImplementation,
+import {
+  type CallOptions,
+  ChannelCredentials,
+  Client,
+  type ClientDuplexStream,
+  type ClientOptions,
+  type ClientReadableStream,
+  type ClientUnaryCall,
+  type ClientWritableStream,
+  type handleBidiStreamingCall,
+  type handleClientStreamingCall,
+  type handleServerStreamingCall,
+  type handleUnaryCall,
+  makeGenericClientConstructor,
+  Metadata,
+  type ServiceError,
+  type UntypedServiceImplementation,
 } from "@grpc/grpc-js";
 import { Empty } from "./google/protobuf/empty";
 import { ListValue, Struct, Value } from "./google/protobuf/struct";
diff --git a/integration/nestjs-metadata-grpc-js/hero.ts b/integration/nestjs-metadata-grpc-js/hero.ts
index ba008e03..1eba9d09 100644
--- a/integration/nestjs-metadata-grpc-js/hero.ts
+++ b/integration/nestjs-metadata-grpc-js/hero.ts
@@ -3,8 +3,12 @@
 
 /* eslint-disable */
 import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
-import { Metadata } from "@grpc/grpc-js";
-import type { handleBidiStreamingCall, handleUnaryCall, UntypedServiceImplementation } from "@grpc/grpc-js";
+import {
+  type handleBidiStreamingCall,
+  type handleUnaryCall,
+  Metadata,
+  type UntypedServiceImplementation,
+} from "@grpc/grpc-js";
 import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices";
 import { Observable } from "rxjs";
 
diff --git a/integration/use-duration/google/protobuf/duration.ts b/integration/use-duration/google/protobuf/duration.ts
new file mode 100644
index 00000000..acb1cf22
--- /dev/null
+++ b/integration/use-duration/google/protobuf/duration.ts
@@ -0,0 +1,139 @@
+// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
+// versions:
+//   protoc-gen-ts_proto  v2.6.1
+//   protoc               v5.28.3
+// source: google/protobuf/duration.proto
+
+/* eslint-disable */
+
+export const protobufPackage = "google.protobuf";
+
+/**
+ * A Duration represents a signed, fixed-length span of time represented
+ * as a count of seconds and fractions of seconds at nanosecond
+ * resolution. It is independent of any calendar and concepts like "day"
+ * or "month". It is related to Timestamp in that the difference between
+ * two Timestamp values is a Duration and it can be added or subtracted
+ * from a Timestamp. Range is approximately +-10,000 years.
+ *
+ * # Examples
+ *
+ * Example 1: Compute Duration from two Timestamps in pseudo code.
+ *
+ *     Timestamp start = ...;
+ *     Timestamp end = ...;
+ *     Duration duration = ...;
+ *
+ *     duration.seconds = end.seconds - start.seconds;
+ *     duration.nanos = end.nanos - start.nanos;
+ *
+ *     if (duration.seconds < 0 && duration.nanos > 0) {
+ *       duration.seconds += 1;
+ *       duration.nanos -= 1000000000;
+ *     } else if (duration.seconds > 0 && duration.nanos < 0) {
+ *       duration.seconds -= 1;
+ *       duration.nanos += 1000000000;
+ *     }
+ *
+ * Example 2: Compute Timestamp from Timestamp + Duration in pseudo code.
+ *
+ *     Timestamp start = ...;
+ *     Duration duration = ...;
+ *     Timestamp end = ...;
+ *
+ *     end.seconds = start.seconds + duration.seconds;
+ *     end.nanos = start.nanos + duration.nanos;
+ *
+ *     if (end.nanos < 0) {
+ *       end.seconds -= 1;
+ *       end.nanos += 1000000000;
+ *     } else if (end.nanos >= 1000000000) {
+ *       end.seconds += 1;
+ *       end.nanos -= 1000000000;
+ *     }
+ *
+ * Example 3: Compute Duration from datetime.timedelta in Python.
+ *
+ *     td = datetime.timedelta(days=3, minutes=10)
+ *     duration = Duration()
+ *     duration.FromTimedelta(td)
+ *
+ * # JSON Mapping
+ *
+ * In JSON format, the Duration type is encoded as a string rather than an
+ * object, where the string ends in the suffix "s" (indicating seconds) and
+ * is preceded by the number of seconds, with nanoseconds expressed as
+ * fractional seconds. For example, 3 seconds with 0 nanoseconds should be
+ * encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should
+ * be expressed in JSON format as "3.000000001s", and 3 seconds and 1
+ * microsecond should be expressed in JSON format as "3.000001s".
+ */
+export interface Duration {
+  /**
+   * Signed seconds of the span of time. Must be from -315,576,000,000
+   * to +315,576,000,000 inclusive. Note: these bounds are computed from:
+   * 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years
+   */
+  seconds: number;
+  /**
+   * Signed fractions of a second at nanosecond resolution of the span
+   * of time. Durations less than one second are represented with a 0
+   * `seconds` field and a positive or negative `nanos` field. For durations
+   * of one second or more, a non-zero value for the `nanos` field must be
+   * of the same sign as the `seconds` field. Must be from -999,999,999
+   * to +999,999,999 inclusive.
+   */
+  nanos: number;
+}
+
+function createBaseDuration(): Duration {
+  return { seconds: 0, nanos: 0 };
+}
+
+export const Duration: MessageFns<Duration> = {
+  fromJSON(object: string): Duration {
+    const match = object.match(/^(-?[0-9.]+)s$/);
+    if (!match) {
+      throw new Error("Invalid duration string");
+    }
+    const seconds = Number(match[1]);
+    const wholeSeconds = Math.trunc(seconds);
+    const nanos = Math.round((seconds - wholeSeconds) * 1e9);
+    return { seconds: wholeSeconds, nanos };
+  },
+
+  toJSON(message: Duration): string {
+    const secs = Number(message.seconds);
+    const nanos = message.nanos;
+    return `${secs + nanos / 1e9}s`;
+  },
+
+  create<I extends Exact<DeepPartial<Duration>, I>>(base?: I): Duration {
+    return Duration.fromPartial(base ?? ({} as any));
+  },
+  fromPartial<I extends Exact<DeepPartial<Duration>, I>>(object: I): Duration {
+    const message = createBaseDuration();
+    message.seconds = object.seconds ?? 0;
+    message.nanos = object.nanos ?? 0;
+    return message;
+  },
+};
+
+type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
+
+export type DeepPartial<T> = T extends Builtin ? T
+  : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
+    : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
+      : T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
+        : Partial<T>;
+
+type KeysOfUnion<T> = T extends T ? keyof T : never;
+export type Exact<P, I extends P> = P extends Builtin ? P
+  : P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
+
+export interface MessageFns<T> {
+  fromJSON(object: any): T;
+  toJSON(message: T): unknown;
+  create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
+  fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
+}
diff --git a/integration/use-duration/parameters.txt b/integration/use-duration/parameters.txt
new file mode 100644
index 00000000..47676199
--- /dev/null
+++ b/integration/use-duration/parameters.txt
@@ -0,0 +1,1 @@
+useOptionals=all,outputEncodeMethods=false,outputClientImpl=false,onlyTypes=true,useJsonWireFormat=true,useDuration=string
diff --git a/package.json b/package.json
index 2d0a6707..095dbc81 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,13 @@
 {
   "name": "ts-proto",
-  "version": "2.6.1",
+  "version": "2.7.1",
   "description": "",
   "main": "build/src/plugin.js",
   "repository": "github:stephenh/ts-proto",
   "bin": {
     "protoc-gen-ts_proto": "./protoc-gen-ts_proto"
   },
-  "types": "build/index.d.ts",
+  "types": "build/src/index.d.ts",
   "scripts": {
     "build": "yarn tsc",
     "build:test": "yarn proto2ts && yarn proto2pbjs",
@@ -69,7 +69,7 @@
   "dependencies": {
     "@bufbuild/protobuf": "^2.0.0",
     "case-anything": "^2.1.13",
-    "ts-poet": "^6.7.0",
+    "ts-poet": "^6.12.0",
     "ts-proto-descriptors": "2.0.0"
   },
   "packageManager": "yarn@4.4.0"
diff --git a/src/main.ts b/src/main.ts
index 43adf848..1f8f29e5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -43,6 +43,7 @@ import {
 import {
   addTypeToMessages,
   DateOption,
+  DurationOption,
   EnvOption,
   JsonTimestampOption,
   LongOption,
@@ -2159,6 +2160,20 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string,
   const { options, utils, currentFile } = ctx;
   const chunks: Code[] = [];
 
+  // Handle Duration string format as a special case
+  if (fullTypeName === "google.protobuf.Duration" && ctx.options.useDuration === DurationOption.STRING) {
+    return code`
+      fromJSON(object: string): ${fullName} {
+        const match = object.match(/^(-?[0-9.]+)s$/);
+        if (!match) throw new Error('Invalid duration string');
+        const seconds = Number(match[1]);
+        const wholeSeconds = Math.trunc(seconds);
+        const nanos = Math.round((seconds - wholeSeconds) * 1e9);
+        return { seconds: wholeSeconds, nanos };
+      }
+    `;
+  }
+
   // create the basic function declaration
   chunks.push(code`
     fromJSON(${messageDesc.field.length > 0 ? "object" : "_"}: any): ${fullName} {
@@ -2404,7 +2419,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string,
 function generateCanonicalToJson(
   fullName: string,
   fullProtobufTypeName: string,
-  { useOptionals, useNullAsOptional }: Options,
+  { useOptionals, useNullAsOptional, useDuration }: Options,
 ): Code | undefined {
   if (isFieldMaskTypeName(fullProtobufTypeName)) {
     const returnType = useOptionals === "all" ? `string | ${nullOrUndefined({ useNullAsOptional })}` : "string";
@@ -2416,6 +2431,15 @@ function generateCanonicalToJson(
     }
   `;
   }
+  if (fullProtobufTypeName === "google.protobuf.Duration" && useDuration === DurationOption.STRING) {
+    return code`
+    toJSON(message: ${fullName}): string {
+        const secs = Number(message.seconds);
+        const nanos = message.nanos;
+        return \`\${secs + nanos/1e9}s\`;
+    }
+    `;
+  }
   return undefined;
 }
 
diff --git a/src/options.ts b/src/options.ts
index ac6bfb85..2de38d8b 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -19,6 +19,11 @@ export enum JsonTimestampOption {
   RAW = "raw",
 }
 
+export enum DurationOption {
+  DURATION = "duration",
+  STRING = "string",
+}
+
 export enum EnvOption {
   NODE = "node",
   BROWSER = "browser",
@@ -114,6 +119,7 @@ export type Options = {
   bigIntLiteral: boolean;
   typePrefix: string;
   typeSuffix: string;
+  useDuration: DurationOption;
 };
 
 export function defaultOptions(): Options {
@@ -186,6 +192,7 @@ export function defaultOptions(): Options {
     bigIntLiteral: true,
     typePrefix: "",
     typeSuffix: "",
+    useDuration: DurationOption.DURATION,
   };
 }
 
diff --git a/yarn.lock b/yarn.lock
index c2e026fd..abbc4c99 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8011,12 +8011,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ts-poet@npm:^6.7.0":
-  version: 6.7.0
-  resolution: "ts-poet@npm:6.7.0"
+"ts-poet@npm:^6.12.0":
+  version: 6.12.0
+  resolution: "ts-poet@npm:6.12.0"
   dependencies:
     dprint-node: "npm:^1.0.8"
-  checksum: 10/563f50a50f89423cabf0a00ec8bf020112814ded877170b177947c84a4698385a1b9eefd3a3eb4e19a4284e37f3ad1b28c5387f36bcfb7e2a242a621a2d6943b
+  checksum: 10/0b0c2802625f2ecb3341abce998dd4c5797d16656871202c790a1dc8ca154c4ab923cee8cba816d00764766bd2c1a5d11517c0d21ce77819f174ba78e2aa732d
   languageName: node
   linkType: hard
 
@@ -8069,7 +8069,7 @@ __metadata:
     rxjs: "npm:^7.8.1"
     semantic-release: "npm:^24.0.0"
     ts-jest: "npm:^29.1.1"
-    ts-poet: "npm:^6.7.0"
+    ts-poet: "npm:^6.12.0"
     ts-proto-descriptors: "npm:2.0.0"
     tsx: "npm:^4.17.0"
     typescript: "npm:^5.5.4"


Test patch:
diff --git a/integration/use-duration/use-duration-test.ts b/integration/use-duration/use-duration-test.ts
new file mode 100644
index 00000000..06e768d5
--- /dev/null
+++ b/integration/use-duration/use-duration-test.ts
@@ -0,0 +1,12 @@
+import { Duration } from "./google/protobuf/duration";
+
+describe('useDuration', () => {
+  it('generates a type that converts to/from string', () => {
+    let t: Duration = {
+      seconds: 123,
+      nanos: 456,
+    }
+    expect(Duration.toJSON(t)).toEqual('123.000000456s');
+    expect(Duration.fromJSON('123.000000456s')).toEqual(t);
+  });
+});
diff --git a/tests/options-test.ts b/tests/options-test.ts
index 5eea15dd..d4aac7c2 100644
--- a/tests/options-test.ts
+++ b/tests/options-test.ts
@@ -62,6 +62,7 @@ describe("options", () => {
         "useAbortSignal": false,
         "useAsyncIterable": false,
         "useDate": "timestamp",
+        "useDuration": "duration",
         "useExactTypes": true,
         "useJsTypeOverride": false,
         "useJsonName": false,