Skip to content

feat(ts): opt-in import_style=modules and oneof_style=discriminated for TS generators#198

Closed
hishamank wants to merge 4 commits into
mainfrom
feat/ts-import-style-modules
Closed

feat(ts): opt-in import_style=modules and oneof_style=discriminated for TS generators#198
hishamank wants to merge 4 commits into
mainfrom
feat/ts-import-style-modules

Conversation

@hishamank

Copy link
Copy Markdown
Collaborator

Summary

Adds two orthogonal, opt-in plugin options to both TypeScript generators (protoc-gen-ts-client, protoc-gen-ts-server), parsed from the protoc parameter string:

  • import_style=modules — emit one canonical type module per proto file (<proto>.ts) with cross-file imports, plus a single shared errors.ts, instead of inlining the full type closure into every service file. Service files become slimmed clients/servers that import their request/response types. Fixes the type-duplication problem (e.g. SongID/ValidationError re-declared in every service file).
  • oneof_style=discriminated — render un-annotated (non-synthetic) oneofs as $case discriminated unions instead of flattened optional fields. An explicit sebuf.http oneof_config annotation still wins.

Defaults are import_style=inline / oneof_style=flatten.

Backward compatibility

With no options, output is byte-for-byte identical to today. This is enforced by the design: the existing emitter functions become thin wrappers that delegate to new *Ctx variants with a nil context; a nil/inline context resolves every type reference to its bare name and records no import. All existing goldens are unchanged (the new behavior lives in new golden subdirectories).

⚠️ import_style=modules requires strategy: all

The modules layout emits shared files (errors.ts, cross-package type modules). buf's default strategy: directory invokes the plugin once per directory, which would emit those shared files repeatedly. Set strategy: all on the plugin in buf.gen.yaml when using import_style=modules:

plugins:
  - local: protoc-gen-ts-client
    out: ./web
    strategy: all
    opt:
      - import_style=modules
      - oneof_style=discriminated

Implementation

  • internal/tscommon/config.goOptions + ParseOptions (ignores unknown keys like paths=source_relative; errors on bad values).
  • internal/tscommon/imports.goEmitContext + ImportTracker (relative specifier computation, deterministic collision aliasing, type-only vs value imports).
  • internal/tscommon/modules.goEmitSharedModules: per-proto type modules grouped by source file + shared errors.ts; reserved-name guard (ValidationError/ApiError/FieldViolation).
  • internal/tscommon/types.go*Ctx variants of the emitters (nil-wrapper preserves defaults); SynthesizeOneofInfo ($case); MessagesBySourceFile/EnumsBySourceFile.
  • Generators branch to modules mode and thread an EmitContext through type/enum reference sites; the shared type modules emitted by client and server are byte-identical.

Tests / verification

  • go vet ./... + full suite green (9 packages). Existing inline goldens unchanged.
  • New golden cases (separate subdirs) for both generators: import_style=modules (per-proto module + slimmed service file + shared errors.ts) and oneof_style=discriminated ($case unions). Client/server shared type modules verified byte-identical.
  • Unit tests: ParseOptions, RelativeImportSpecifier, ImportTracker (collision aliasing, render ordering).
  • End-to-end against a real multi-package schema (anghamna, with strategy: all): 98 files, 0 duplicate-file warnings, SongID defined once and imported, oneofs discriminated, and the full tree type-checks under tsc --strict --moduleResolution bundler.

Follow-ups (out of scope)

  • A dedicated multi-package golden fixture (cross-package imports are covered by the e2e run for now).
  • Docs page for the TS generator options.

hishamank and others added 3 commits June 30, 2026 11:55
…-in)

Both TS generators (protoc-gen-ts-client, protoc-gen-ts-server) gain two
orthogonal, opt-in plugin options parsed from the protoc parameter string:

- import_style=modules: emit one canonical type module per proto file
  (<proto>.ts) with cross-file imports + a single shared errors.ts, instead
  of inlining the full type closure into every service file. Service files
  become slimmed clients/servers that import their request/response types.
- oneof_style=discriminated: render un-annotated (non-synthetic) oneofs as
  $case discriminated unions instead of flattened optional fields.

Defaults (inline / flatten) are byte-identical to today — implemented via
*Ctx emitter variants in tscommon with the existing functions kept as
nil-context wrappers, so no existing golden changes.

Core pieces: tscommon/config.go (ParseOptions), tscommon/imports.go
(EmitContext + ImportTracker + relative-path resolution), tscommon/modules.go
(shared per-proto type modules + errors), and import-aware *Ctx variants in
tscommon/types.go. Generators branch to modules mode and thread ctx through
type/enum reference sites.

Verified: go vet + full suite green (9 pkgs); existing inline goldens
unchanged; modules output type-checks (client).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scriminated

Add an `opts`/`subdir` field to both TS golden harnesses so opt-variant
cases generate into testdata/golden/<subdir>/. New cases on existing
fixtures: import_style=modules (complex_features -> per-proto type module +
slimmed client/server + shared errors.ts) and oneof_style=discriminated
(oneof_discriminator -> $case unions). Default cases keep empty opts and
unchanged goldens, guarding byte-identical default output. Client and server
emit byte-identical shared type modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inline (default) generation path now builds an inline EmitContext from
the parsed options and emits interfaces through GenerateInterfaceCtx, so
oneof_style=discriminated takes effect without import_style=modules. With the
default flatten style the inline context resolves bare names and records no
imports, keeping output byte-identical (existing goldens unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🔍 CI Pipeline Status

Lint: failure
Test: success
Coverage: success
⚠️ Build: skipped
⚠️ Integration: skipped


📊 Coverage Report: Available in checks above
🔗 Artifacts: Test results and coverage reports uploaded

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 26.50273% with 269 lines in your changes missing coverage. Please review.
✅ Project coverage is 4.09%. Comparing base (ad6a1b5) to head (30482a9).

Files with missing lines Patch % Lines
internal/tscommon/modules.go 0.00% 82 Missing ⚠️
internal/tscommon/types.go 0.00% 66 Missing ⚠️
internal/tscommon/imports.go 66.34% 33 Missing and 2 partials ⚠️
internal/tsservergen/modules.go 0.00% 29 Missing ⚠️
internal/tsclientgen/modules.go 0.00% 26 Missing ⚠️
internal/tsservergen/generator.go 0.00% 18 Missing ⚠️
internal/tsclientgen/generator.go 0.00% 11 Missing ⚠️
internal/tscommon/config.go 93.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##            main    #198      +/-   ##
========================================
+ Coverage   3.32%   4.09%   +0.76%     
========================================
  Files         62      67       +5     
  Lines      10966   11290     +324     
========================================
+ Hits         365     462      +97     
- Misses     10595   10819     +224     
- Partials       6       9       +3     
Flag Coverage Δ
unittests 4.09% <26.50%> (+0.76%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The shared errors import previously always pulled ApiError, FieldViolation
and ValidationError, tripping noUnusedLocals in strict consumers (clients
never use FieldViolation; servers never use ApiError). Scan the buffered body
and import only the referenced helpers — clients get {ApiError,
ValidationError}, servers get {FieldViolation, ValidationError}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hishamank

Copy link
Copy Markdown
Collaborator Author

Split into two independently-reviewable PRs so each feature can be accepted on its own:

Closing this combined PR in favor of those.

@hishamank hishamank closed this Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant