sorbet-typescript 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 20b6b038eabc0014a293fcd994c56e1413d217613fa86c0df40157dc3e4399a2
4
+ data.tar.gz: 331239897ffb2f29a4b52dd09987d1fecdbbb298f9cfd8a42dc7898e0034889c
5
+ SHA512:
6
+ metadata.gz: 28d320cb930debcab937be85c99d676743469c3a223a6d9b3e1ae50d89b14e123a7d81f698126cde66f978d0147fddaadf43422da9d5e941fe1c31e52d6b46be
7
+ data.tar.gz: b7656d8962f845ed7ff48afa5a507416dfd0accdac1f8a6ff641a2561b58056fb85db80471cf78244e62a3bbc2df7511246bd1adb5189ebc0137df512b990cbf
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ - Initial development release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Nathan Broadbent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Sorbet::Typescript
2
+
3
+ Sorbet::Typescript exports Sorbet `T::Enum` and `T::Struct` definitions into JSON metadata and TypeScript declarations so front-end clients can stay in sync with your Ruby types.
4
+
5
+ ## Background
6
+
7
+ DocSpring built this gem for [LogStruct](https://github.com/DocSpring/logstruct) to keep its documented payload types in lockstep with the Ruby source. The generated metadata powers the live reference at [logstruct.com/docs/sorbet-types](https://logstruct.com/docs/sorbet-types/) and all sample logs published across [logstruct.com](https://logstruct.com/), ensuring every example stays current through code generation.
8
+
9
+ See how LogStruct wires the exporter into its build pipeline in [`scripts/generate_structs.rb`](https://github.com/DocSpring/logstruct/blob/main/scripts/generate_structs.rb).
10
+
11
+ ## Installation
12
+
13
+ Add the gem to your application:
14
+
15
+ ```bash
16
+ bundle add sorbet-typescript
17
+ ```
18
+
19
+ If you need the latest unreleased changes, point bundler at GitHub:
20
+
21
+ ```bash
22
+ bundle add sorbet-typescript --git https://github.com/DocSpring/sorbet-typescript
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Command line
28
+
29
+ The executable `sorbet-typescript` inspects the namespaces you specify and writes JSON and/or TypeScript files:
30
+
31
+ ```bash
32
+ bundle exec sorbet-typescript export \
33
+ --struct-namespace MyApp::Types \
34
+ --enum-namespace MyApp::Enums \
35
+ --typescript-file app/frontend/types/generated.ts
36
+ ```
37
+
38
+ You can also drive the export with a YAML config (default path `sorbet_typescript.yml`):
39
+
40
+ ```yaml
41
+ requires:
42
+ - config/environment
43
+ struct_namespaces:
44
+ - MyApp::Types
45
+ enum_namespaces:
46
+ - MyApp::Enums
47
+ outputs:
48
+ typescript_file: app/frontend/types/generated.ts
49
+ enums_json: tmp/sorbet/enums.json
50
+ structs_json: tmp/sorbet/structs.json
51
+ ```
52
+
53
+ Run it with `bundle exec sorbet-typescript export --config sorbet_typescript.yml`. Any CLI options override the config file.
54
+
55
+ ### Ruby API
56
+
57
+ If you want to script the export yourself, use the `Exporter` directly:
58
+
59
+ ```ruby
60
+ exporter = Sorbet::Typescript::Exporter.new(
61
+ enum_namespaces: ["MyApp::Enums"],
62
+ struct_namespaces: ["MyApp::Types"]
63
+ )
64
+
65
+ exporter.export(
66
+ typescript_file: "app/frontend/types/generated.ts",
67
+ enums_json: "tmp/sorbet/enums.json",
68
+ structs_json: "tmp/sorbet/structs.json"
69
+ )
70
+ ```
71
+
72
+ The returned `ExportData` object contains `enums` and `structs` hashes that you can leverage for custom tooling.
73
+
74
+ ## Development
75
+
76
+ After cloning the repo, install dependencies and run the task workflow:
77
+
78
+ ```bash
79
+ task setup
80
+ task all
81
+ ```
82
+
83
+ Useful individual commands:
84
+
85
+ - `task typecheck` – run Sorbet (`srb tc`) with the strict sigils enforced in the repo
86
+ - `task lint` / `task lint:fix` – Standard/RuboCop lint checks and auto-corrections
87
+ - `task test` – execute the Minitest suite via `scripts/test.rb`
88
+
89
+ Make sure you have [Go Task](https://taskfile.dev/#/installation) installed to run the `task` command.
90
+
91
+ ## Releasing
92
+
93
+ Bump the version in `lib/sorbet/typescript/version.rb`, then run:
94
+
95
+ ```bash
96
+ bundle exec rake release
97
+ ```
98
+
99
+ This creates a git tag, pushes the commit and tag, and publishes the gem.
100
+
101
+ ## License
102
+
103
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Taskfile.yml ADDED
@@ -0,0 +1,41 @@
1
+ version: '3'
2
+
3
+ tasks:
4
+ setup:
5
+ desc: Install Ruby dependencies
6
+ cmds:
7
+ - bundle install
8
+
9
+ typecheck:
10
+ desc: Run Sorbet type checking
11
+ cmds:
12
+ - ./scripts/typecheck.sh
13
+
14
+ lint:
15
+ desc: Run RuboCop lint checks
16
+ cmds:
17
+ - bundle exec rubocop
18
+
19
+ lint:fix:
20
+ desc: Auto-fix RuboCop issues
21
+ cmds:
22
+ - bundle exec rubocop -A
23
+
24
+ test:
25
+ desc: Run the Ruby test suite
26
+ cmds:
27
+ - ./scripts/test.rb
28
+
29
+ all:
30
+ aliases: [ci]
31
+ desc: Run the full validation workflow
32
+ cmds:
33
+ - task: typecheck
34
+ - task: lint
35
+ - task: test
36
+
37
+ fix:
38
+ desc: Auto-fix lint, then rerun validation
39
+ cmds:
40
+ - task: lint:fix
41
+ - task: all
@@ -0,0 +1,96 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "thor"
6
+ require "sorbet-runtime"
7
+
8
+ class Sorbet; end unless defined?(Sorbet)
9
+
10
+ module Sorbet::Typescript
11
+ class CLI < Thor
12
+ extend T::Sig
13
+
14
+ DEFAULT_CONFIG = "sorbet_typescript.yml"
15
+
16
+ class_option :config, type: :string, desc: "Path to configuration file"
17
+
18
+ desc "export", "Export Sorbet enums and structs to JSON and TypeScript files"
19
+ option :require, type: :array, desc: "Files to require before exporting"
20
+ option :struct_namespace, type: :array, desc: "Namespaces containing T::Struct subclasses"
21
+ option :enum_namespace, type: :array, desc: "Namespaces containing T::Enum subclasses"
22
+ option :enums_json, type: :string, desc: "Output path for enums JSON"
23
+ option :structs_json, type: :string, desc: "Output path for structs JSON"
24
+ option :typescript_file, type: :string, desc: "Output path for generated TypeScript definitions"
25
+ option :property_map_json, type: :string, desc: "Output path for property metadata JSON"
26
+ def export
27
+ opts = symbolize_keys(options.to_h)
28
+ configuration = load_configuration(opts[:config])
29
+
30
+ Array(configuration.fetch("requires", [])).concat(opts[:require] || []).each do |requirement|
31
+ require requirement
32
+ end
33
+
34
+ enum_namespaces = resolved_namespace_list(
35
+ configuration,
36
+ opts,
37
+ key: "enum_namespaces",
38
+ option_key: :enum_namespace
39
+ )
40
+ struct_namespaces = resolved_namespace_list(
41
+ configuration,
42
+ opts,
43
+ key: "struct_namespaces",
44
+ option_key: :struct_namespace
45
+ )
46
+
47
+ outputs = T.cast(configuration.fetch("outputs", {}), T::Hash[String, T.untyped])
48
+
49
+ exporter = Exporter.new(
50
+ enum_namespaces: enum_namespaces,
51
+ struct_namespaces: struct_namespaces
52
+ )
53
+
54
+ result = exporter.export(
55
+ enums_json: opts[:enums_json] || outputs["enums_json"],
56
+ structs_json: opts[:structs_json] || outputs["structs_json"],
57
+ typescript_file: opts[:typescript_file] || outputs["typescript_file"],
58
+ property_map_json: opts[:property_map_json] || outputs["property_map_json"]
59
+ )
60
+
61
+ say "Exported #{result.enums.keys.size} enums and #{result.structs.keys.size} structs"
62
+ rescue Error => e
63
+ raise Thor::Error, e.message
64
+ end
65
+
66
+ private
67
+
68
+ sig { params(config_path: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
69
+ def load_configuration(config_path)
70
+ path = config_path || (File.exist?(DEFAULT_CONFIG) ? DEFAULT_CONFIG : nil)
71
+ return {} unless path
72
+
73
+ YAML.load_file(path) || {}
74
+ rescue Errno::ENOENT
75
+ raise Error, "Configuration file not found: #{path}"
76
+ rescue Psych::SyntaxError => e
77
+ raise Error, "Unable to parse configuration file #{path}: #{e.message}"
78
+ end
79
+
80
+ sig do
81
+ params(configuration: T::Hash[String, T.untyped], options: T::Hash[Symbol, T.untyped], key: String, option_key: Symbol)
82
+ .returns(T::Array[T.any(String, Module)])
83
+ end
84
+ def resolved_namespace_list(configuration, options, key:, option_key:)
85
+ cli_value = options[option_key]
86
+ return Array(cli_value) if cli_value && !cli_value.empty?
87
+
88
+ Array(configuration.fetch(key, []))
89
+ end
90
+
91
+ sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
92
+ def symbolize_keys(hash)
93
+ hash.transform_keys { |key| key.to_sym }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,546 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "date"
6
+ require "fileutils"
7
+ require "sorbet-runtime"
8
+
9
+ class Sorbet; end unless defined?(Sorbet)
10
+
11
+ module Sorbet::Typescript
12
+ class Exporter
13
+ extend T::Sig
14
+
15
+ class ExportData < T::Struct
16
+ const :enums, T::Hash[String, T::Hash[Symbol, T.untyped]]
17
+ const :structs, T::Hash[String, T::Hash[Symbol, T.untyped]]
18
+ end
19
+
20
+ sig do
21
+ params(
22
+ enum_namespaces: T::Array[T.any(String, Module)],
23
+ struct_namespaces: T::Array[T.any(String, Module)]
24
+ ).void
25
+ end
26
+ def initialize(enum_namespaces: [], struct_namespaces: [])
27
+ @enum_namespaces = normalize_namespaces(enum_namespaces)
28
+ @struct_namespaces = normalize_namespaces(struct_namespaces)
29
+ end
30
+
31
+ sig do
32
+ params(
33
+ enums_json: T.nilable(String),
34
+ structs_json: T.nilable(String),
35
+ typescript_file: T.nilable(String),
36
+ property_map_json: T.nilable(String)
37
+ ).returns(ExportData)
38
+ end
39
+ def export(enums_json: nil, structs_json: nil, typescript_file: nil, property_map_json: nil)
40
+ data = generate_data
41
+
42
+ write_json(enums_json, data.enums) if enums_json
43
+ write_json(structs_json, data.structs) if structs_json
44
+ write_property_map(property_map_json, data.structs) if property_map_json
45
+ write_typescript(typescript_file, data) if typescript_file
46
+
47
+ data
48
+ end
49
+
50
+ sig { returns(ExportData) }
51
+ def generate_data
52
+ enums = export_enums
53
+ structs = export_structs
54
+ ExportData.new(enums: enums, structs: structs)
55
+ end
56
+
57
+ private
58
+
59
+ sig { params(enum_namespaces: T::Array[T.any(String, Module)]).returns(T::Array[String]) }
60
+ def normalize_namespaces(enum_namespaces)
61
+ enum_namespaces.map do |namespace|
62
+ case namespace
63
+ when Module
64
+ namespace.name || ""
65
+ else
66
+ namespace.to_s
67
+ end
68
+ end.reject(&:empty?)
69
+ end
70
+
71
+ sig { returns(T::Array[T.class_of(T::Enum)]) }
72
+ def enum_classes
73
+ T::Enum.subclasses.to_a.compact.select do |klass|
74
+ klass_name = klass.name
75
+ klass_name && in_namespaces?(klass_name, @enum_namespaces)
76
+ end.sort_by { |klass| T.must(klass.name) }
77
+ end
78
+
79
+ sig { returns(T::Array[T.class_of(T::Struct)]) }
80
+ def struct_classes
81
+ T::Struct.subclasses.to_a.compact.select do |klass|
82
+ klass_name = klass.name
83
+ klass_name && in_namespaces?(klass_name, @struct_namespaces)
84
+ end.sort_by { |klass| T.must(klass.name) }
85
+ end
86
+
87
+ sig { params(name: String, namespaces: T::Array[String]).returns(T::Boolean) }
88
+ def in_namespaces?(name, namespaces)
89
+ return true if namespaces.empty?
90
+
91
+ namespaces.any? do |namespace|
92
+ name == namespace || name.start_with?("#{namespace}::")
93
+ end
94
+ end
95
+
96
+ sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) }
97
+ def export_enums
98
+ enum_classes.each_with_object({}) do |enum_class, memo|
99
+ class_name = T.must(enum_class.name)
100
+ values = enum_class.values.map do |value|
101
+ {
102
+ name: enum_constant_name(value),
103
+ serialized: value.serialize
104
+ }
105
+ end
106
+
107
+ memo[class_name] = {
108
+ name: class_name,
109
+ simple_name: class_name.split("::").last,
110
+ values: values
111
+ }
112
+ end
113
+ end
114
+
115
+ sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) }
116
+ def export_structs
117
+ struct_classes.each_with_object({}) do |struct_class, memo|
118
+ class_name = T.must(struct_class.name)
119
+ fields = struct_class.props.each_with_object({}) do |(field, prop_info), acc|
120
+ acc[field.to_s] = extract_type_info(prop_info)
121
+ end
122
+
123
+ memo[class_name] = {
124
+ name: class_name,
125
+ simple_name: class_name.split("::").last,
126
+ fields: fields
127
+ }
128
+ end
129
+ end
130
+
131
+ sig { params(path: String, data: T::Hash[String, T::Hash[Symbol, T.untyped]]).void }
132
+ def write_json(path, data)
133
+ ensure_dir(File.dirname(path))
134
+ File.write(path, JSON.pretty_generate(stringify_keys(data)))
135
+ end
136
+
137
+ sig { params(path: String, structs: T::Hash[String, T::Hash[Symbol, T.untyped]]).void }
138
+ def write_property_map(path, structs)
139
+ return unless path
140
+
141
+ mapping = structs.each_with_object({}) do |(struct_name, info), acc|
142
+ fields = T.cast(info[:fields], T::Hash[String, T::Hash[Symbol, T.untyped]])
143
+ fields.each do |field_name, field_info|
144
+ key = "#{struct_name}.#{field_name}"
145
+ acc[key] = field_info
146
+ end
147
+ end
148
+
149
+ write_json(path, mapping)
150
+ end
151
+
152
+ sig { params(path: String, data: ExportData).void }
153
+ def write_typescript(path, data)
154
+ return unless path
155
+
156
+ ensure_dir(File.dirname(path))
157
+
158
+ enums = data.enums
159
+ structs = data.structs
160
+
161
+ lines = []
162
+ lines << "// @generated by sorbet-typescript"
163
+ lines << "/* eslint-disable */"
164
+ lines << ""
165
+
166
+ enums.keys.sort.each do |enum_name|
167
+ enum_info = enums.fetch(enum_name)
168
+ ts_name = typescript_identifier(enum_name)
169
+ values = T.cast(enum_info[:values], T::Array[T::Hash[Symbol, T.untyped]])
170
+
171
+ union = values.map { |value| %("#{value[:serialized]}") }.join(" | ")
172
+ literal_values = values.map { |value| %("#{value[:serialized]}") }
173
+
174
+ lines << "export type #{ts_name} = #{union};"
175
+ lines << "export const #{ts_name}Values = [#{literal_values.join(", ")}] as const;"
176
+ lines << ""
177
+ end
178
+
179
+ structs.keys.sort.each do |struct_name|
180
+ struct_info = structs.fetch(struct_name)
181
+ ts_name = typescript_identifier(struct_name)
182
+ fields = T.cast(struct_info[:fields], T::Hash[String, T::Hash[Symbol, T.untyped]])
183
+
184
+ lines << "export interface #{ts_name} {"
185
+ fields.keys.sort.each do |field_name|
186
+ field_info = fields.fetch(field_name)
187
+ optional = field_info[:optional] ? "?" : ""
188
+ ts_type = typescript_type_for(field_info, enums)
189
+ lines << " #{field_name}#{optional}: #{ts_type};"
190
+ end
191
+ lines << "}"
192
+ lines << ""
193
+ end
194
+
195
+ contents = lines.join("\n").rstrip + "\n"
196
+ File.write(path, contents)
197
+ end
198
+
199
+ sig { params(field_info: T::Hash[Symbol, T.untyped], enums: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(String) }
200
+ def typescript_type_for(field_info, enums)
201
+ case field_info[:type]
202
+ when "enum"
203
+ ts_name = field_info[:enum_name]
204
+ return "unknown" unless ts_name
205
+
206
+ enum_key = T.cast(ts_name, String)
207
+ if enums.key?(enum_key)
208
+ typescript_identifier(enum_key)
209
+ else
210
+ typescript_identifier(enum_key.split("::").last)
211
+ end
212
+ when "enum_single"
213
+ details = enum_value_details_for(field_info)
214
+ detail = details.first
215
+ literal = detail&.fetch(:serialized, nil) || detail&.fetch(:name, nil) || field_info[:enum_value_serialized] || field_info[:enum_value]
216
+ literal ? %("#{literal}") : "unknown"
217
+ when "enum_union"
218
+ details = enum_value_details_for(field_info)
219
+ return "unknown" if details.empty?
220
+
221
+ base_enum = field_info[:base_enum] || simple_enum_name(field_info[:enum_name])
222
+
223
+ details.map do |detail|
224
+ serialized = detail[:serialized]
225
+ constant_name = detail[:name]
226
+
227
+ if base_enum && serialized
228
+ "#{base_enum}.#{serialized.upcase}"
229
+ elsif base_enum && constant_name
230
+ "#{base_enum}.#{constant_name.upcase}"
231
+ else
232
+ %("#{serialized || constant_name}")
233
+ end
234
+ end.join(" | ")
235
+ when "array"
236
+ item = T.cast(field_info[:item], T::Hash[Symbol, T.untyped])
237
+ "#{typescript_type_for(item, enums)}[]"
238
+ when "struct"
239
+ ts_name = field_info[:struct_name]
240
+ ts_name ? typescript_identifier(ts_name) : "Record<string, unknown>"
241
+ when "object", "hash"
242
+ "Record<string, unknown>"
243
+ when "string"
244
+ "string"
245
+ when "integer", "number"
246
+ "number"
247
+ when "boolean"
248
+ "boolean"
249
+ when "symbol"
250
+ "string"
251
+ else
252
+ "unknown"
253
+ end
254
+ end
255
+
256
+ sig { params(name: String).returns(String) }
257
+ def typescript_identifier(name)
258
+ name.split("::").join
259
+ end
260
+
261
+ sig { params(prop_info: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
262
+ def extract_type_info(prop_info)
263
+ type_object = prop_info[:type_object] || prop_info[:type]
264
+ info = analyse_type_object(type_object)
265
+ info = augment_enum_metadata(info)
266
+ info[:optional] = prop_info[:_tnilable] || prop_info[:fully_optional] || false
267
+ info
268
+ end
269
+
270
+ sig { params(obj: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
271
+ def analyse_type_object(obj)
272
+ case obj
273
+ when T::Types::TypedArray
274
+ item_type = array_inner_type(obj)
275
+ item_meta = analyse_type_object(item_type)
276
+ {type: "array", item: item_meta}
277
+ when T::Types::TypedHash
278
+ {type: "object"}
279
+ when T::Private::Types::SimplePairUnion
280
+ inner_types = T.cast(obj.instance_variable_get(:@types), T::Array[T::Types::Base])
281
+ non_nil = inner_types.find do |inner|
282
+ !(inner.is_a?(T::Types::Simple) && inner.raw_type == NilClass)
283
+ end
284
+ non_nil ? analyse_type_object(resolve_type(inner: non_nil)) : {type: "any"}
285
+ when T::Types::Union
286
+ analyse_union(obj)
287
+ when T::Types::Simple
288
+ analyse_type_object(obj.raw_type)
289
+ when T::Types::TEnum
290
+ enum_value = obj.val
291
+ {
292
+ type: "enum_single",
293
+ enum_name: enum_value.class.name,
294
+ enum_values: [enum_value_metadata(enum_value)]
295
+ }
296
+ when T::Types::Untyped
297
+ {type: "any"}
298
+ when Class, Module
299
+ analyse_class(obj)
300
+ when Symbol
301
+ {type: "symbol"}
302
+ else
303
+ analyse_by_string(obj.to_s)
304
+ end
305
+ end
306
+
307
+ sig { params(inner: T::Types::Base).returns(T.untyped) }
308
+ def resolve_type(inner:)
309
+ if inner.is_a?(T::Types::Simple)
310
+ inner.raw_type
311
+ elsif inner.is_a?(T::Types::TEnum)
312
+ inner
313
+ elsif inner.respond_to?(:inner_type)
314
+ inner
315
+ else
316
+ inner
317
+ end
318
+ end
319
+
320
+ sig { params(array_type: T::Types::TypedArray).returns(T.untyped) }
321
+ def array_inner_type(array_type)
322
+ if array_type.respond_to?(:inner_type)
323
+ array_type.inner_type
324
+ else
325
+ array_type.instance_variable_get(:@inner_type)
326
+ end
327
+ end
328
+
329
+ sig { params(obj: T::Types::Union).returns(T::Hash[Symbol, T.untyped]) }
330
+ def analyse_union(obj)
331
+ inner_types = T.cast(obj.instance_variable_get(:@types), T::Array[T::Types::Base])
332
+
333
+ enum_values = []
334
+ enum_class = nil
335
+ other_types = []
336
+
337
+ inner_types.each do |inner|
338
+ case inner
339
+ when T::Types::TEnum
340
+ enum_value = inner.val
341
+ enum_class ||= enum_value.class
342
+
343
+ if enum_value.instance_of?(enum_class)
344
+ enum_values << enum_value_metadata(enum_value)
345
+ else
346
+ other_types << inner
347
+ end
348
+ when T::Types::Simple
349
+ raw = inner.raw_type
350
+ next if raw == NilClass
351
+
352
+ other_types << inner
353
+ else
354
+ other_types << inner
355
+ end
356
+ end
357
+
358
+ if enum_values.any? && other_types.empty?
359
+ unique_values = enum_values.uniq { |value| value[:serialized] }
360
+ enum_name = enum_class&.name
361
+
362
+ if unique_values.length == 1
363
+ return {
364
+ type: "enum_single",
365
+ enum_name: enum_name,
366
+ enum_values: unique_values
367
+ }
368
+ end
369
+
370
+ return {
371
+ type: "enum_union",
372
+ enum_name: enum_name,
373
+ enum_values: unique_values
374
+ }
375
+ end
376
+
377
+ resolved_non_nil = inner_types.map { |inner| resolve_type(inner: inner) }.reject { |type| type == NilClass }
378
+ return {type: "any"} if resolved_non_nil.empty?
379
+
380
+ if resolved_non_nil.length == 1
381
+ return analyse_type_object(resolved_non_nil.first)
382
+ end
383
+
384
+ {type: "any"}
385
+ end
386
+
387
+ sig { params(klass: Module).returns(T::Hash[Symbol, T.untyped]) }
388
+ def analyse_class(klass)
389
+ if klass <= T::Enum
390
+ {
391
+ type: "enum",
392
+ enum_name: klass.name
393
+ }
394
+ elsif klass <= T::Struct
395
+ {
396
+ type: "struct",
397
+ struct_name: klass.name
398
+ }
399
+ elsif klass <= String
400
+ {type: "string"}
401
+ elsif klass <= Symbol
402
+ {type: "symbol"}
403
+ elsif klass <= Integer
404
+ {type: "integer"}
405
+ elsif klass <= Float
406
+ {type: "number"}
407
+ elsif klass <= TrueClass || klass <= FalseClass
408
+ {type: "boolean"}
409
+ elsif klass <= Time || klass <= Date || klass <= DateTime
410
+ {type: "string", format: "date-time"}
411
+ elsif klass <= Hash
412
+ {type: "hash"}
413
+ elsif klass <= Array
414
+ {type: "array", item: {type: "any"}}
415
+ else
416
+ {type: "any", ruby_type: klass.name}
417
+ end
418
+ end
419
+
420
+ sig { params(type: String).returns(T::Hash[Symbol, T.untyped]) }
421
+ def analyse_by_string(type)
422
+ case type
423
+ when /String/
424
+ {type: "string"}
425
+ when /Integer/
426
+ {type: "integer"}
427
+ when /Float|Numeric/
428
+ {type: "number"}
429
+ when /Boolean|TrueClass|FalseClass/
430
+ {type: "boolean"}
431
+ when /Array/
432
+ {type: "array", item: {type: "any"}}
433
+ when /Hash/
434
+ {type: "hash"}
435
+ else
436
+ {type: "any"}
437
+ end
438
+ end
439
+
440
+ sig { params(enum_value: T.untyped).returns(T.nilable(String)) }
441
+ def enum_constant_name(enum_value)
442
+ const_name = enum_value.instance_variable_get(:@const_name)
443
+ return unless const_name
444
+
445
+ const_name.to_s.delete_prefix(":")
446
+ end
447
+
448
+ sig { params(enum_value: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
449
+ def enum_value_metadata(enum_value)
450
+ {
451
+ name: enum_constant_name(enum_value),
452
+ serialized: enum_value.serialize
453
+ }
454
+ end
455
+
456
+ sig { params(info: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
457
+ def augment_enum_metadata(info)
458
+ case info[:type]
459
+ when "enum"
460
+ enum_name = info[:enum_name]
461
+ info[:values] ||= simple_enum_name(enum_name)
462
+ when "enum_single"
463
+ details = enum_value_details_for(info)
464
+ info[:enum_value_details] = details if details.any?
465
+ info[:base_enum] ||= simple_enum_name(info[:enum_name])
466
+
467
+ first = details.first
468
+ if first
469
+ info[:enum_value] ||= first[:name]
470
+ info[:enum_value_serialized] ||= first[:serialized]
471
+ end
472
+ when "enum_union"
473
+ details = enum_value_details_for(info)
474
+ info[:enum_value_details] = details if details.any?
475
+ info[:base_enum] ||= simple_enum_name(info[:enum_name])
476
+
477
+ if details.any?
478
+ info[:enum_values] = details.map { |detail| detail[:name] }
479
+ info[:enum_values_serialized] = details.map { |detail| detail[:serialized] }
480
+ end
481
+ end
482
+
483
+ info
484
+ end
485
+
486
+ sig { params(info: T::Hash[Symbol, T.untyped]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
487
+ def enum_value_details_for(info)
488
+ details = info[:enum_value_details]
489
+ if details.is_a?(Array) && details.first.is_a?(Hash)
490
+ return T.cast(details, T::Array[T::Hash[Symbol, T.untyped]])
491
+ end
492
+
493
+ raw_values = info[:enum_values]
494
+ return [] unless raw_values.is_a?(Array)
495
+
496
+ if raw_values.first.is_a?(Hash)
497
+ return T.cast(raw_values, T::Array[T::Hash[Symbol, T.untyped]])
498
+ end
499
+
500
+ enum_class = enum_class_by_name(info[:enum_name])
501
+ return [] unless enum_class
502
+
503
+ raw_values.filter_map do |const_name|
504
+ next unless const_name.is_a?(String)
505
+
506
+ enum_class.values.find do |value|
507
+ enum_constant_name(value) == const_name
508
+ end&.then { |enum_value| enum_value_metadata(enum_value) }
509
+ end
510
+ end
511
+
512
+ sig { params(enum_name: T.untyped).returns(T.nilable(T.class_of(T::Enum))) }
513
+ def enum_class_by_name(enum_name)
514
+ return nil unless enum_name.is_a?(String)
515
+
516
+ enum_classes.find { |klass| klass.name == enum_name }
517
+ end
518
+
519
+ sig { params(enum_name: T.untyped).returns(T.nilable(String)) }
520
+ def simple_enum_name(enum_name)
521
+ return nil unless enum_name.is_a?(String)
522
+
523
+ enum_name.split("::").last
524
+ end
525
+
526
+ sig { params(path: String).void }
527
+ def ensure_dir(path)
528
+ FileUtils.mkdir_p(path) unless File.directory?(path)
529
+ end
530
+
531
+ sig { params(value: T.untyped).returns(T.untyped) }
532
+ def stringify_keys(value)
533
+ case value
534
+ when Hash
535
+ value.each_with_object({}) do |(k, v), hash|
536
+ key = k.is_a?(Symbol) ? k.to_s : k
537
+ hash[key] = stringify_keys(v)
538
+ end
539
+ when Array
540
+ value.map { |item| stringify_keys(item) }
541
+ else
542
+ value
543
+ end
544
+ end
545
+ end
546
+ end
@@ -0,0 +1,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ class Sorbet; end unless defined?(Sorbet)
5
+
6
+ module Sorbet::Typescript
7
+ VERSION = "0.1.0"
8
+ end
@@ -0,0 +1,14 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require_relative "typescript/version"
7
+ require_relative "typescript/exporter"
8
+ require_relative "typescript/cli"
9
+
10
+ class Sorbet; end unless defined?(Sorbet)
11
+
12
+ module Sorbet::Typescript
13
+ class Error < StandardError; end
14
+ end
@@ -0,0 +1,39 @@
1
+ class Sorbet
2
+ end
3
+
4
+ module Sorbet::Typescript
5
+ VERSION: String
6
+
7
+ class Error < StandardError
8
+ end
9
+
10
+ class Exporter
11
+ def initialize: (
12
+ enum_namespaces: Array[untyped] ?,
13
+ struct_namespaces: Array[untyped] ?
14
+ ) -> void
15
+
16
+ def export: (
17
+ enums_json: String?
18
+ structs_json: String?
19
+ typescript_file: String?
20
+ property_map_json: String?
21
+ ) -> ExportData
22
+
23
+ def generate_data: () -> ExportData
24
+
25
+ class ExportData
26
+ attr_reader enums: Hash[String, Hash[Symbol, untyped]]
27
+ attr_reader structs: Hash[String, Hash[Symbol, untyped]]
28
+
29
+ def initialize: (
30
+ enums: Hash[String, Hash[Symbol, untyped]],
31
+ structs: Hash[String, Hash[Symbol, untyped]]
32
+ ) -> void
33
+ end
34
+ end
35
+
36
+ class CLI < ::Thor
37
+ def export: (*untyped) -> void
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/sorbet/typescript/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "sorbet-typescript"
7
+ spec.version = Sorbet::Typescript::VERSION
8
+ spec.authors = ["DocSpring"]
9
+ spec.email = ["support@docspring.com"]
10
+
11
+ spec.summary = "Export Sorbet enums and structs into TypeScript-ready metadata."
12
+ spec.description = "Sorbet TypeScript bridges Sorbet RBI definitions to TypeScript by introspecting T::Enum and T::Struct classes and emitting JSON metadata and helper typings."
13
+ spec.homepage = "https://github.com/DocSpring/sorbet-typescript"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir[
23
+ "lib/**/*",
24
+ "sig/**/*",
25
+ "README.md",
26
+ "CHANGELOG.md",
27
+ "LICENSE.txt",
28
+ "Taskfile*",
29
+ "*.gemspec"
30
+ ]
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+
35
+ spec.add_dependency "sorbet-runtime", ">= 0.5"
36
+ spec.add_dependency "thor", ">= 1.3"
37
+
38
+ spec.add_development_dependency "minitest", ">= 5.20"
39
+ spec.add_development_dependency "rake", ">= 13.0"
40
+ spec.add_development_dependency "rubocop", ">= 1.59"
41
+ spec.add_development_dependency "rubocop-performance", ">= 1.20"
42
+ spec.add_development_dependency "rubocop-sorbet", ">= 0.8"
43
+ spec.add_development_dependency "sorbet", ">= 0.5"
44
+ spec.add_development_dependency "tapioca", ">= 0.13"
45
+ end
metadata ADDED
@@ -0,0 +1,181 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sorbet-typescript
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DocSpring
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-09-28 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sorbet-runtime
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: thor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '5.20'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '5.20'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '1.59'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '1.59'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-performance
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.20'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '1.20'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop-sorbet
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0.8'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0.8'
110
+ - !ruby/object:Gem::Dependency
111
+ name: sorbet
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0.5'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0.5'
124
+ - !ruby/object:Gem::Dependency
125
+ name: tapioca
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0.13'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0.13'
138
+ description: Sorbet TypeScript bridges Sorbet RBI definitions to TypeScript by introspecting
139
+ T::Enum and T::Struct classes and emitting JSON metadata and helper typings.
140
+ email:
141
+ - support@docspring.com
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - CHANGELOG.md
147
+ - LICENSE.txt
148
+ - README.md
149
+ - Taskfile.yml
150
+ - lib/sorbet/typescript.rb
151
+ - lib/sorbet/typescript/cli.rb
152
+ - lib/sorbet/typescript/exporter.rb
153
+ - lib/sorbet/typescript/version.rb
154
+ - sig/sorbet/typescript.rbs
155
+ - sorbet-typescript.gemspec
156
+ homepage: https://github.com/DocSpring/sorbet-typescript
157
+ licenses:
158
+ - MIT
159
+ metadata:
160
+ homepage_uri: https://github.com/DocSpring/sorbet-typescript
161
+ source_code_uri: https://github.com/DocSpring/sorbet-typescript
162
+ changelog_uri: https://github.com/DocSpring/sorbet-typescript/blob/main/CHANGELOG.md
163
+ rubygems_mfa_required: 'true'
164
+ rdoc_options: []
165
+ require_paths:
166
+ - lib
167
+ required_ruby_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: 3.2.0
172
+ required_rubygems_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: '0'
177
+ requirements: []
178
+ rubygems_version: 3.6.3
179
+ specification_version: 4
180
+ summary: Export Sorbet enums and structs into TypeScript-ready metadata.
181
+ test_files: []