typelizer 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0784a5e0472196bc334c145c9f02e6f98898d164670abd55e4215775ed4b0aaa'
4
- data.tar.gz: fc4dcaf1daddc6f9d07068e36fdc6ff7d2be2f8fe3163b42460e1fbaa6e5671b
3
+ metadata.gz: a9a7d9dfc60b5b5b8432575016f4f63697b6c6e356d74044abf6646b7d8e10a6
4
+ data.tar.gz: 374654bfb99f5bb3363b7a21174bb4c4a52718d2b852d4597e80aa155ce6fa34
5
5
  SHA512:
6
- metadata.gz: 80275894a434d028d8e89aaaa746476fce065785201374488b92ba967866bad458852cf5c57864027076b44c48225e596435c772fc0a05bcc26bf7c312eb219d
7
- data.tar.gz: c3f99f8cb337d3fc3a33c6fda789c23957907440f0162c437b379f8f1f2a1f61416348cc86386bf2c686a9060aee55a8ffc796baf2d42e4296b0cf77d9249468
6
+ metadata.gz: 3886301d0db29ef0ed101f5edf76e6228501672f5750c0109a854e142f880c1cd24a71fea9ca352caa888697f801302283b679e36ac61ea19c87e8c9b73ecfb4
7
+ data.tar.gz: bf5b5cdc3748500355aa1bac6d2f0dc035a4776080e06bee523c369cf33177ed24f1e92702e42366e5a7b1e72994696ff32dc52f55295f66bbf48f7961c5e2a5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning].
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-05-01
11
+
12
+ ### Added
13
+
14
+ - **Runtime enums** (`config.enum_runtime = true`, default `false`): emit an `as const` object alongside each named enum type in `Enums.ts` and re-export it as a value from `index.ts`. Lets consumers compare against enum values at runtime (`if (user.role === UserRole.admin)`) without hand-maintaining a parallel constants file. Existing type-only output is unchanged when the flag is off. ([@skryukov])
15
+
16
+ - **Inline object types**: pass a positional hash to `typelize` to describe an inline TypeScript object type. Nested hashes nest, and options like `multi:`/`nullable:` compose. Useful for JSON columns and ad-hoc computed shapes that don't warrant a separate resource. ([@skryukov])
17
+
18
+ ```ruby
19
+ typelize({id: :number, label?: :string})
20
+ attribute :category
21
+ # → category: { id: number; label?: string }
22
+ ```
23
+
24
+ - **`?` suffix on attribute keys** as a shorthand for `optional: true`, mirroring TypeScript's own syntax. Works both in keyed `typelize` calls and inside inline shape hashes. ([@skryukov])
25
+
26
+ ```ruby
27
+ typelize name?: :string
28
+ # → name?: string
29
+ ```
30
+
31
+ ### Changed
32
+
33
+ - [BREAKING] Type generation is now deferred to the first HTTP request via a Rack middleware, instead of running during Rails boot. Boot no longer touches the database, so a missing or pending-migration database no longer crashes server startup. Generation failures raise `Typelizer::TypeGenerationError` (rendered like the standard pending-migrations page) and are retried on the next request. Rake tasks are unaffected. ([@julik])
34
+ - [BREAKING] Bumped `railties` requirement to `>= 6.1.0` to use the `server` Railtie block. ([@julik])
35
+ - [BREAKING] Dropped `DISABLE_TYPELIZER` environment variable support (deprecated since 0.12.0). Use `TYPELIZER=false` instead. ([@skryukov])
36
+ - [BREAKING] Removed the `.form` route variant, `FormDefinition` type, and `formAction` runtime helper. ([@skryukov])
37
+
38
+ ### Fixed
39
+
40
+ - `typelize` declarations silently dropped during rake tasks, producing `unknown` for every field. ([#114](https://github.com/skryukov/typelizer/issues/114)) ([@skryukov])
41
+ - `properties_transformer` now applied to nested attribute sub-properties, meta nested blocks, and Alba trait properties. Previously only top-level keys were transformed, producing inconsistent output. ([#89](https://github.com/skryukov/typelizer/issues/89)) ([@skryukov])
42
+ - `typelize "Name[]"` paired with `with_traits:` no longer emits a phantom trait intersection with a missing import. Explicit `typelize` overrides are now trusted as-is — the generated type is exactly what you wrote. ([#113](https://github.com/skryukov/typelizer/issues/113)) ([@skryukov])
43
+ - ActiveRecord models whose tables don't exist yet (fresh checkout, pending migration) are now skipped during type inference instead of issuing live DB queries. ([@julik])
44
+
10
45
  ## [0.12.0] - 2026-03-29
11
46
 
12
47
  ### Added
@@ -455,6 +490,7 @@ and this project adheres to [Semantic Versioning].
455
490
  [@Envek]: https://github.com/Envek
456
491
  [@hkamberovic]: https://github.com/hkamberovic
457
492
  [@jonmarkgo]: https://github.com/jonmarkgo
493
+ [@julik]: https://github.com/julik
458
494
  [@kristinemcbride]: https://github.com/kristinemcbride
459
495
  [@nkriege]: https://github.com/nkriege
460
496
  [@NOX73]: https://github.com/NOX73
@@ -467,7 +503,8 @@ and this project adheres to [Semantic Versioning].
467
503
  [@skryukov]: https://github.com/skryukov
468
504
  [@ventsislaf]: https://github.com/ventsislaf
469
505
 
470
- [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.12.0...HEAD
506
+ [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.13.0...HEAD
507
+ [0.13.0]: https://github.com/skryukov/typelizer/compare/v0.12.0...v0.13.0
471
508
  [0.12.0]: https://github.com/skryukov/typelizer/compare/v0.11.0...v0.12.0
472
509
  [0.11.0]: https://github.com/skryukov/typelizer/compare/v0.10.0...v0.11.0
473
510
  [0.10.0]: https://github.com/skryukov/typelizer/compare/v0.9.3...v0.10.0
data/README.md CHANGED
@@ -50,6 +50,24 @@ rails typelizer:generate
50
50
  - [Configuration](https://typelizer.dev/reference/configuration)
51
51
  - [Type Mapping](https://typelizer.dev/reference/type-mapping)
52
52
 
53
+ ## Development
54
+
55
+ You need PostgreSQL running locally. Then:
56
+
57
+ ```bash
58
+ bundle install
59
+ cd spec/app && RAILS_ENV=test bundle exec rails db:create db:migrate && cd ../..
60
+ bundle exec rspec
61
+ ```
62
+
63
+ The test suite uses a dummy Rails app in `spec/app/` with models, migrations, and serializers for all four supported frameworks (Alba, AMS, OjSerializers, Panko). Linting is done with StandardRB:
64
+
65
+ ```bash
66
+ bundle exec standardrb
67
+ ```
68
+
69
+ `bundle exec rake` runs both the tests and the linter.
70
+
53
71
  ## Credits
54
72
 
55
73
  Typelizer is inspired by [types_from_serializers](https://github.com/ElMassimo/types_from_serializers), [js-routes](https://github.com/railsware/js-routes), and [Wayfinder](https://github.com/nicholasvansanten/wayfinder).
@@ -31,12 +31,17 @@ module Typelizer
31
31
  properties_sort_order
32
32
  ].freeze
33
33
 
34
- # Subset of CONFIGS_AFFECTING_OUTPUT that specifically affect index.ts output.
35
- CONFIGS_AFFECTING_INDEX_OUTPUT = %i[
34
+ # Config keys that affect only index.ts and Enums.ts (not per-interface .ts files).
35
+ CONFIGS_AFFECTING_INDEX_ONLY_OUTPUT = %i[
36
+ enum_runtime
37
+ ].freeze
38
+
39
+ # Config keys that affect index.ts output (superset: per-interface keys + index-only keys).
40
+ CONFIGS_AFFECTING_INDEX_OUTPUT = (%i[
36
41
  verbatim_module_syntax
37
42
  prefer_double_quotes
38
43
  imports_sort_order
39
- ].freeze
44
+ ] + CONFIGS_AFFECTING_INDEX_ONLY_OUTPUT).freeze
40
45
 
41
46
  # Config keys that don't affect file content (runtime behavior, or effects captured via properties).
42
47
  CONFIGS_NOT_AFFECTING_OUTPUT = %i[
@@ -76,6 +81,7 @@ module Typelizer
76
81
  :reject_class,
77
82
  :comments,
78
83
  :prefer_double_quotes,
84
+ :enum_runtime,
79
85
  keyword_init: true
80
86
  )
81
87
 
@@ -114,6 +120,7 @@ module Typelizer
114
120
  reject_class: ->(serializer:) { false },
115
121
  comments: false,
116
122
  prefer_double_quotes: false,
123
+ enum_runtime: false,
117
124
 
118
125
  output_dir: -> { default_output_dir },
119
126
 
data/lib/typelizer/dsl.rb CHANGED
@@ -33,8 +33,6 @@ module Typelizer
33
33
 
34
34
  # save association of serializer to model
35
35
  def typelize_from(model)
36
- return unless Typelizer.enabled?
37
-
38
36
  define_singleton_method(:_typelizer_model_name) { model }
39
37
  end
40
38
 
@@ -82,12 +80,13 @@ module Typelizer
82
80
  private
83
81
 
84
82
  def assign_type_information(attribute_name, attributes)
85
- return unless Typelizer.enabled?
86
-
87
83
  attributes.each do |name, attrs|
88
84
  next unless name
89
85
 
90
- store_type(attribute_name, name, TypeParser.parse_declaration(attrs))
86
+ clean_name, optional_from_key = TypeParser.parse_key(name)
87
+ parsed = TypeParser.apply_optional_key(TypeParser.parse_declaration(attrs), optional_from_key)
88
+
89
+ store_type(attribute_name, clean_name, parsed)
91
90
  end
92
91
  end
93
92
 
@@ -112,5 +111,15 @@ module Typelizer
112
111
  end
113
112
  end
114
113
  end
114
+
115
+ module Disabled
116
+ %i[typelize_from typelize typelize_meta].each do |name|
117
+ define_method(name) { |*, **| }
118
+ end
119
+ end
120
+
121
+ def self.disable!
122
+ ClassMethods.prepend(Disabled)
123
+ end
115
124
  end
116
125
  end
@@ -64,7 +64,7 @@ module Typelizer
64
64
  @meta_fields ||= begin
65
65
  props = serializer_plugin.meta_fields || []
66
66
  props = infer_types(props, :_typelizer_meta_attributes)
67
- props = config.properties_transformer.call(props) if config.properties_transformer
67
+ props = transform_properties(props)
68
68
  PropertySorter.sort(props, config.properties_sort_order)
69
69
  end
70
70
  end
@@ -88,7 +88,7 @@ module Typelizer
88
88
  @properties ||= begin
89
89
  props = serializer_plugin.properties
90
90
  props = infer_types(props)
91
- props = config.properties_transformer.call(props) if config.properties_transformer
91
+ props = transform_properties(props)
92
92
  PropertySorter.sort(props, config.properties_sort_order)
93
93
  end
94
94
  end
@@ -125,7 +125,10 @@ module Typelizer
125
125
  # recursively including nested sub-properties
126
126
  all_properties = collect_all_properties(properties_to_print + trait_interfaces.flat_map(&:properties))
127
127
 
128
- flat_types = all_properties.filter_map(&:type).flat_map { |t| Array(t) }.uniq
128
+ flat_types = all_properties.filter_map(&:type)
129
+ .flat_map { |t| Array(t) }
130
+ .reject { |t| t.is_a?(Shape) }
131
+ .uniq
129
132
  association_serializers, attribute_types = flat_types.partition { |type| type.is_a?(Interface) }
130
133
 
131
134
  serializer_types = association_serializers
@@ -136,13 +139,10 @@ module Typelizer
136
139
  .uniq
137
140
  .reject { |type| global_type?(type) }
138
141
 
139
- # Collect trait types from properties with with_traits (skip self-references)
140
142
  trait_imports = all_properties.flat_map do |prop|
141
- next [] unless prop.with_traits&.any? && prop.type.is_a?(Interface)
142
- # Skip if the trait types are from the current interface (same file)
143
- next [] if prop.type.name == name
143
+ next [] if prop.type.is_a?(Interface) && prop.type.name == name
144
144
 
145
- prop.with_traits.map { |t| "#{prop.type.name}#{t.to_s.camelize}Trait" }
145
+ prop.trait_type_names
146
146
  end
147
147
 
148
148
  # Collect enum type names from properties
@@ -177,13 +177,15 @@ module Typelizer
177
177
 
178
178
  def collect_all_properties(props)
179
179
  props.flat_map do |prop|
180
- if prop.nested_properties&.any?
181
- [prop] + collect_all_properties(prop.nested_properties)
182
- elsif prop.type.is_a?(Interface) && prop.type.inline?
183
- [prop] + collect_all_properties(prop.type.properties)
184
- else
185
- [prop]
186
- end
180
+ children = nested_properties_of(prop.type)
181
+ children ? [prop] + collect_all_properties(children) : [prop]
182
+ end
183
+ end
184
+
185
+ def nested_properties_of(type)
186
+ case type
187
+ when Shape then type.properties
188
+ when Interface then type.properties if type.inline?
187
189
  end
188
190
  end
189
191
 
@@ -204,7 +206,7 @@ module Typelizer
204
206
  multi_attrs = serializer.respond_to?(:_typelizer_multi_attributes) ? serializer._typelizer_multi_attributes : Set.new
205
207
 
206
208
  props.map do |prop|
207
- has_dsl = dsl_attrs_for(prop, dsl_attrs)&.any?
209
+ has_dsl = prop.lookup_in(dsl_attrs)&.any?
208
210
 
209
211
  prop
210
212
  .then { |p| apply_dsl_type(p, dsl_attrs) }
@@ -215,12 +217,8 @@ module Typelizer
215
217
  end
216
218
  end
217
219
 
218
- def dsl_attrs_for(prop, dsl_attrs)
219
- dsl_attrs[prop.column_name.to_sym] || dsl_attrs[prop.name.to_sym]
220
- end
221
-
222
220
  def apply_dsl_type(prop, dsl_attrs)
223
- dsl_type = dsl_attrs_for(prop, dsl_attrs)
221
+ dsl_type = prop.lookup_in(dsl_attrs)
224
222
  return prop unless dsl_type&.any?
225
223
 
226
224
  dsl_type = resolve_class_type(dsl_type)
@@ -235,11 +233,20 @@ module Typelizer
235
233
  resolve_union_class_types(attrs)
236
234
  when String, Symbol
237
235
  resolve_single_class_type(attrs)
236
+ when Shape
237
+ attrs.merge(type: resolve_shape(type))
238
238
  else
239
239
  attrs
240
240
  end
241
241
  end
242
242
 
243
+ def resolve_shape(shape)
244
+ shape.map_properties do |p|
245
+ resolved = resolve_class_type(type: p.type)
246
+ p.with(type: resolved[:type])
247
+ end
248
+ end
249
+
243
250
  def resolve_single_class_type(attrs)
244
251
  attrs.merge(type: resolve_type_part(attrs[:type]))
245
252
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ class TypeGenerationError < StandardError; end
5
+
6
+ class Middleware
7
+ class << self
8
+ attr_accessor :instance
9
+ end
10
+
11
+ def initialize(app)
12
+ @app = app
13
+ @mutex = Mutex.new
14
+ @pending = true
15
+ self.class.instance = self
16
+ end
17
+
18
+ def call(env)
19
+ if @pending
20
+ @mutex.synchronize do
21
+ generate! if @pending
22
+ end
23
+ end
24
+ @app.call(env)
25
+ end
26
+
27
+ def mark_pending!
28
+ @pending = true
29
+ end
30
+
31
+ private
32
+
33
+ def generate!
34
+ Generator.new.call
35
+ RouteGenerator.call
36
+ @pending = false
37
+ rescue *db_error_classes => e
38
+ raise TypeGenerationError, "Typelizer could not generate types: #{e.message}\n" \
39
+ "Fix the database issue, then reload the page."
40
+ end
41
+
42
+ def db_error_classes
43
+ return [] unless defined?(ActiveRecord)
44
+
45
+ [
46
+ ActiveRecord::NoDatabaseError,
47
+ ActiveRecord::ConnectionNotEstablished,
48
+ ActiveRecord::StatementInvalid
49
+ ]
50
+ end
51
+ end
52
+ end
@@ -38,6 +38,7 @@ module Typelizer
38
38
  def columns_hash
39
39
  return nil unless model_class
40
40
  return nil if model_class.abstract_class?
41
+ return nil unless model_class.table_exists?
41
42
 
42
43
  model_class.columns_hash
43
44
  end
@@ -45,6 +46,7 @@ module Typelizer
45
46
  def attribute_types
46
47
  return nil unless model_class&.respond_to?(:attribute_types)
47
48
  return nil if model_class.abstract_class?
49
+ return nil unless model_class.table_exists?
48
50
 
49
51
  model_class.attribute_types
50
52
  end
@@ -126,6 +128,7 @@ module Typelizer
126
128
  return nil unless assoc
127
129
 
128
130
  target = assoc.klass
131
+ return nil unless target.table_exists?
129
132
  col = target.columns_hash[info[:original].to_s]
130
133
  return nil unless col
131
134
 
@@ -32,13 +32,7 @@ module Typelizer
32
32
  validate_version!(openapi_version)
33
33
 
34
34
  type_mapping = interface.respond_to?(:config) ? interface.config.type_mapping : Typelizer.configuration.type_mapping
35
- required_props = interface.properties.reject(&:optional).map(&:name)
36
- schema = {
37
- type: :object,
38
- properties: interface.properties.to_h { |prop| [prop.name, property_schema(prop, openapi_version: openapi_version, type_mapping: type_mapping)] }
39
- }
40
- schema[:required] = required_props if required_props.any?
41
- schema
35
+ object_schema(interface.properties, openapi_version: openapi_version, type_mapping: type_mapping)
42
36
  end
43
37
 
44
38
  def property_schema(property, openapi_version: "3.0", type_mapping: Typelizer.configuration.type_mapping)
@@ -120,11 +114,10 @@ module Typelizer
120
114
  end
121
115
 
122
116
  def wrap_traits(definition, property, openapi_version:)
123
- return definition unless property.respond_to?(:with_traits) && property.with_traits&.any? && property.type.respond_to?(:name)
117
+ trait_names = property.trait_type_names
118
+ return definition if trait_names.empty?
124
119
 
125
- trait_refs = property.with_traits.map do |t|
126
- {"$ref" => "#/components/schemas/#{property.type.name}#{t.to_s.camelize}Trait"}
127
- end
120
+ trait_refs = trait_names.map { |name| {"$ref" => "#/components/schemas/#{name}"} }
128
121
 
129
122
  base_ref = definition.delete("$ref")
130
123
  if base_ref
@@ -173,14 +166,15 @@ module Typelizer
173
166
  end
174
167
 
175
168
  def base_type(property, openapi_version:, type_mapping:)
176
- if property.type.respond_to?(:properties)
169
+ # Shape check must precede respond_to?(:properties) — Shape also responds to :properties.
170
+ if property.type.is_a?(Shape)
171
+ object_schema(property.type.properties, openapi_version: openapi_version, type_mapping: type_mapping)
172
+ elsif property.type.respond_to?(:properties)
177
173
  if property.type.respond_to?(:inline?) && property.type.inline?
178
174
  schema_for(property.type, openapi_version: openapi_version)
179
175
  else
180
176
  {"$ref" => "#/components/schemas/#{property.type.name}"}
181
177
  end
182
- elsif property.type.nil? && property.respond_to?(:nested_properties) && property.nested_properties&.any?
183
- nested_schema(property, openapi_version: openapi_version, type_mapping: type_mapping)
184
178
  elsif property.column_type && COLUMN_TYPE_MAP.key?(property.column_type) &&
185
179
  !type_mapping_overridden?(property, type_mapping)
186
180
  result = COLUMN_TYPE_MAP[property.column_type].dup
@@ -196,11 +190,11 @@ module Typelizer
196
190
  end
197
191
  end
198
192
 
199
- def nested_schema(property, openapi_version:, type_mapping:)
200
- required = property.nested_properties.reject(&:optional).map(&:name)
193
+ def object_schema(properties, openapi_version:, type_mapping:)
194
+ required = properties.reject(&:optional).map(&:name)
201
195
  schema = {
202
196
  type: :object,
203
- properties: property.nested_properties.to_h { |p| [p.name, property_schema(p, openapi_version: openapi_version, type_mapping: type_mapping)] }
197
+ properties: properties.to_h { |p| [p.name, property_schema(p, openapi_version: openapi_version, type_mapping: type_mapping)] }
204
198
  }
205
199
  schema[:required] = required if required.any?
206
200
  schema
@@ -2,13 +2,17 @@ module Typelizer
2
2
  Property = Struct.new(
3
3
  :name, :type, :optional, :nullable,
4
4
  :multi, :column_name, :column_type, :comment, :enum, :enum_type_name, :deprecated,
5
- :with_traits, :nested_properties, :nested_typelizes,
5
+ :with_traits,
6
6
  keyword_init: true
7
7
  ) do
8
8
  def with(**attrs)
9
9
  dup.tap { |p| attrs.each { |k, v| p[k] = v } }
10
10
  end
11
11
 
12
+ def lookup_in(hash)
13
+ hash[column_name.to_sym] || hash[name.to_sym]
14
+ end
15
+
12
16
  def inspect
13
17
  props = to_h.merge(type: type_name).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
14
18
  "<#{self.class.name} #{props}>"
@@ -25,6 +29,12 @@ module Typelizer
25
29
  render(sort_order: :none)
26
30
  end
27
31
 
32
+ def trait_type_names
33
+ return [] unless with_traits&.any? && type.is_a?(Interface)
34
+
35
+ with_traits.map { |t| "#{type.name}#{t.to_s.camelize}Trait" }
36
+ end
37
+
28
38
  # Renders the property as a TypeScript property string
29
39
  # @param sort_order [Symbol, Proc, nil] Sort order for union types (:none, :alphabetical, or Proc)
30
40
  # @param prefer_double_quotes [Boolean] Whether to use double quotes for string values
@@ -32,11 +42,8 @@ module Typelizer
32
42
  def render(sort_order: :none, prefer_double_quotes: false)
33
43
  type_str = type_name(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes)
34
44
 
35
- # Handle intersection types for traits
36
- if with_traits&.any? && type.respond_to?(:name)
37
- trait_types = with_traits.map { |t| "#{type.name}#{t.to_s.camelize}Trait" }
38
- type_str = ([type_str] + trait_types).join(" & ")
39
- end
45
+ trait_types = trait_type_names
46
+ type_str = ([type_str] + trait_types).join(" & ") if trait_types.any?
40
47
 
41
48
  type_str = "Array<#{type_str}>" if multi
42
49
 
@@ -51,14 +58,10 @@ module Typelizer
51
58
 
52
59
  def fingerprint
53
60
  # Use array format for consistent output across Ruby versions
54
- # (Hash#inspect format changed in Ruby 3.4)
55
- # Exclude fields that do not affect generated TypeScript output.
56
- # Exclude nested_properties/nested_typelizes from to_h to avoid changing
57
- # fingerprints for properties that don't use them.
58
- # nested_typelizes is excluded entirely as it only affects inference, not output.
59
- to_h.except(:column_type, :nested_properties, :nested_typelizes)
61
+ # (Hash#inspect format changed in Ruby 3.4).
62
+ # column_type is excluded because it only informs inference, not output.
63
+ to_h.except(:column_type)
60
64
  .merge(type: UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical))
61
- .then { |h| nested_properties&.any? ? h.merge(nested_properties: nested_properties.map(&:fingerprint)) : h }
62
65
  .to_a.inspect
63
66
  end
64
67
 
@@ -69,17 +72,36 @@ module Typelizer
69
72
  def enum_definition(sort_order: :none, prefer_double_quotes: false)
70
73
  return unless enum && enum_type_name
71
74
 
72
- values = enum.map { |v| quote_string(v.to_s, prefer_double_quotes) }
73
- values = values.sort_by(&:downcase) if sort_order == :alphabetical
75
+ values = sorted_enum_keys(sort_order).map { |k| quote_string(k, prefer_double_quotes) }
74
76
  "type #{enum_type_name} = #{values.join(" | ")}"
75
77
  end
76
78
 
79
+ # Generates a TypeScript runtime constant for named enums
80
+ # @param sort_order [Symbol, Proc, nil] Sort order for enum keys (:none, :alphabetical, or Proc)
81
+ # @param prefer_double_quotes [Boolean] Whether to use double quotes for string values
82
+ # @return [String, nil] The const like "const UserRole = { admin: 'admin', user: 'user' } as const"
83
+ def enum_runtime_definition(sort_order: :none, prefer_double_quotes: false)
84
+ return unless enum && enum_type_name
85
+
86
+ entries = sorted_enum_keys(sort_order).map { |k| "#{js_key(k, prefer_double_quotes)}: #{quote_string(k, prefer_double_quotes)}" }
87
+ "const #{enum_type_name} = { #{entries.join(", ")} } as const"
88
+ end
89
+
77
90
  private
78
91
 
92
+ def sorted_enum_keys(sort_order)
93
+ keys = enum.map(&:to_s)
94
+ (sort_order == :alphabetical) ? keys.sort_by(&:downcase) : keys
95
+ end
96
+
79
97
  def quote_string(str, prefer_double_quotes)
80
98
  prefer_double_quotes ? "\"#{str}\"" : "'#{str}'"
81
99
  end
82
100
 
101
+ def js_key(str, prefer_double_quotes)
102
+ str.match?(/\A[A-Za-z_$][\w$]*\z/) ? str : quote_string(str, prefer_double_quotes)
103
+ end
104
+
83
105
  # Returns the type name, optionally sorting union members
84
106
  # @param sort_order [Symbol, Proc, nil] Sort order for union types
85
107
  # @param prefer_double_quotes [Boolean] Whether to use double quotes for string values
@@ -89,21 +111,12 @@ module Typelizer
89
111
  return enum_type_name if enum_type_name
90
112
 
91
113
  if enum
92
- # Sort enum values if alphabetical sorting is requested
93
- enum_values = enum.map { |v| quote_string(v.to_s, prefer_double_quotes) }
94
- enum_values = enum_values.sort_by(&:downcase) if sort_order == :alphabetical
95
- return enum_values.join(" | ")
96
- end
97
-
98
- if type.nil? && nested_properties&.any?
99
- inner = nested_properties.map { |p|
100
- rendered = p.render(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) + ";"
101
- rendered.gsub(/^/, " ")
102
- }.join("\n")
103
- return "{\n#{inner}\n}"
114
+ return sorted_enum_keys(sort_order).map { |k| quote_string(k, prefer_double_quotes) }.join(" | ")
104
115
  end
105
116
 
106
117
  case type
118
+ when Shape
119
+ type.render(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes)
107
120
  when Array
108
121
  type.map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(" | ")
109
122
  else
@@ -15,23 +15,25 @@ module Typelizer
15
15
  end
16
16
  end
17
17
 
18
- initializer "typelizer.generate" do |app|
18
+ initializer "typelizer.configure_dsl" do
19
+ Typelizer::DSL.disable! unless Typelizer.enabled?
20
+ end
21
+
22
+ server do
19
23
  next unless Typelizer.enabled?
20
24
 
21
- generator = Typelizer::Generator.new
25
+ require_relative "middleware"
26
+ Rails.application.config.app_middleware.use(Typelizer::Middleware)
22
27
 
23
28
  if Typelizer.listen == true || (Gem.loaded_specs["listen"] && Typelizer.listen != false)
24
29
  require_relative "listen"
25
- app.config.after_initialize do
26
- Typelizer::Listen.call do
27
- Rails.application.reloader.reload!
28
- end
30
+ Typelizer::Listen.call(run_on_start: false) do
31
+ Rails.application.reloader.reload!
29
32
  end
30
33
  end
31
34
 
32
- app.config.to_prepare do
33
- generator.call
34
- RouteGenerator.call
35
+ Rails.application.config.to_prepare do
36
+ Typelizer::Middleware.instance&.mark_pending!
35
37
  end
36
38
  end
37
39
  end
@@ -29,6 +29,7 @@ module Typelizer
29
29
  @properties ||= begin
30
30
  props, typelizes = plugin.trait_properties(trait_name)
31
31
  props = infer_types(props, typelizes)
32
+ props = transform_properties(props)
32
33
  PropertySorter.sort(props, config.properties_sort_order)
33
34
  end
34
35
  end
@@ -37,7 +38,7 @@ module Typelizer
37
38
 
38
39
  def infer_types(props, typelizes)
39
40
  props.map do |prop|
40
- dsl_type = typelizes[prop.column_name.to_sym] || typelizes[prop.name.to_sym]
41
+ dsl_type = prop.lookup_in(typelizes)
41
42
  prop
42
43
  .then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
43
44
  .then { |p| apply_metadata(p) }
@@ -47,7 +47,6 @@ module Typelizer
47
47
  trait_block = traits[trait_name]
48
48
  return [], {} unless trait_block
49
49
 
50
- # Create a collector to capture attributes defined in the trait block
51
50
  collector = BlockAttributeCollector.new
52
51
  collector.instance_exec(&trait_block)
53
52
 
@@ -76,16 +75,13 @@ module Typelizer
76
75
  )
77
76
  when BlockAttributeCollector::BlockNestedAttribute
78
77
  prop_name = has_transform_key?(serializer) ? fetch_key(serializer, name) : name
79
- nested_props, nested_typelizes = collect_nested_block(attr.block)
80
78
  Property.new(
81
79
  name: prop_name,
82
- type: nil,
80
+ type: Shape.new(properties: collect_nested_block(attr.block)),
83
81
  optional: false,
84
82
  nullable: false,
85
83
  multi: false,
86
- column_name: name,
87
- nested_properties: nested_props,
88
- nested_typelizes: nested_typelizes
84
+ column_name: name
89
85
  )
90
86
  else
91
87
  build_property(name, attr)
@@ -179,16 +175,13 @@ module Typelizer
179
175
  )
180
176
  when ::Alba::NestedAttribute
181
177
  block = attr.instance_variable_get(:@block)
182
- nested_props, nested_typelizes = collect_nested_block(block)
183
178
  Property.new(
184
179
  name: name,
185
- type: nil,
180
+ type: Shape.new(properties: collect_nested_block(block)),
186
181
  optional: false,
187
182
  nullable: false,
188
183
  multi: false,
189
184
  column_name: column_name,
190
- nested_properties: nested_props,
191
- nested_typelizes: nested_typelizes,
192
185
  **options
193
186
  )
194
187
  when ::Alba::ConditionalAttribute
@@ -217,13 +210,14 @@ module Typelizer
217
210
  def collect_nested_block(block)
218
211
  collector = BlockAttributeCollector.new
219
212
  collector.instance_exec(&block)
213
+ typelizes = collector.collected_typelizes
220
214
 
221
- props = collector.collected_attributes.map do |attr_name, attr|
215
+ collector.collected_attributes.map do |attr_name, attr|
222
216
  attr_name_str = attr_name.is_a?(Symbol) ? attr_name.name : attr_name
223
- build_collected_property(attr_name_str, attr)
217
+ prop = build_collected_property(attr_name_str, attr)
218
+ override = prop.lookup_in(typelizes)
219
+ override&.any? ? prop.with(**override) : prop
224
220
  end
225
-
226
- [props, collector.collected_typelizes]
227
221
  end
228
222
  end
229
223
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ class Shape
5
+ attr_reader :properties
6
+
7
+ def initialize(properties:)
8
+ @properties = properties.freeze
9
+ @fingerprint = ["Shape", properties.map(&:fingerprint)].freeze
10
+ @hash = @fingerprint.hash
11
+ freeze
12
+ end
13
+
14
+ def map_properties
15
+ self.class.new(properties: properties.map { |p| yield p })
16
+ end
17
+
18
+ def render(sort_order: :none, prefer_double_quotes: false)
19
+ inner = properties.map { |p|
20
+ (p.render(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) + ";")
21
+ .gsub(/^/, " ")
22
+ }.join("\n")
23
+ "{\n#{inner}\n}"
24
+ end
25
+
26
+ alias_method :to_s, :render
27
+
28
+ attr_reader :fingerprint, :hash
29
+
30
+ def ==(other)
31
+ other.is_a?(Shape) && fingerprint == other.fingerprint
32
+ end
33
+ alias_method :eql?, :==
34
+
35
+ def inspect
36
+ "<Typelizer::Shape properties=#{properties.inspect}>"
37
+ end
38
+ end
39
+ end
@@ -1,3 +1,6 @@
1
1
  <%- enums.each do |property| -%>
2
2
  export <%= property.enum_definition(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) %>;
3
+ <%- if enum_runtime -%>
4
+ export <%= property.enum_runtime_definition(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) %>;
5
+ <%- end -%>
3
6
  <%- end -%>
@@ -1,6 +1,7 @@
1
1
  <%- if enums.any? -%>
2
2
  <%- enums_path = prefer_double_quotes ? '"./Enums"' : "'./Enums'" -%>
3
- export type { <%= ImportSorter.sort(enums.map(&:enum_type_name), imports_sort_order).join(", ") %> } from <%= enums_path %>
3
+ <%- enum_keyword = enum_runtime ? "export" : "export type" -%>
4
+ <%= enum_keyword %> { <%= ImportSorter.sort(enums.map(&:enum_type_name), imports_sort_order).join(", ") %> } from <%= enums_path %>
4
5
  <%- end -%>
5
6
  <%- interfaces.each do |interface| -%>
6
7
  <%- sorted_traits = ImportSorter.sort(interface.trait_interfaces.map(&:name), interface.config.imports_sort_order) -%>
@@ -1,8 +1,7 @@
1
- <%- has_form = routes.any? { |r| !%w[get post].include?(r[:verb]) } -%>
2
1
  <%- if ts -%>
3
- import type { RouteDefinition<%= ", FormDefinition" if has_form %>, RouteOptions } from '<%= runtime_import %>'
2
+ import type { RouteDefinition, RouteOptions } from '<%= runtime_import %>'
4
3
  <%- end -%>
5
- import { buildUrl<%= ", formAction" if has_form %> } from '<%= runtime_import %>'
4
+ import { buildUrl } from '<%= runtime_import %>'
6
5
 
7
6
  export default {
8
7
  <%- routes.each_with_index do |route, idx| -%>
@@ -10,48 +9,8 @@ export default {
10
9
  <%- parts = route[:required_params].map { |p| "#{p}#{ts ? ": string | number" : ""}" } -%>
11
10
  <%- parts += route[:optional_params].map { |p| "#{p}#{ts ? "?: string | number" : ""}" } -%>
12
11
  <%- has_params = parts.any? -%>
13
- <%- needs_form = !%w[get post].include?(route[:verb]) -%>
14
12
  /** <%= route[:verb].upcase %> <%= route[:path] %> */
15
- <%- if needs_form -%>
16
13
  <%- if has_params -%>
17
- <%- if ts -%>
18
- <%= route[:key] %>: Object.assign(
19
- (
20
- params: { <%= parts.join('; ') %> }<%= " | string | number" if route[:single_required] %>,
21
- options?: RouteOptions,
22
- ): RouteDefinition<<%= "'#{route[:verb]}'" %>> => ({
23
- url: buildUrl('<%= route[:path] %>', params, options),
24
- method: '<%= route[:verb] %>',
25
- }),
26
- { form: (
27
- params: { <%= parts.join('; ') %> }<%= " | string | number" if route[:single_required] %>,
28
- options?: RouteOptions,
29
- ): FormDefinition =>
30
- formAction(buildUrl('<%= route[:path] %>', params, options), '<%= route[:verb] %>') },
31
- ),
32
- <%- else -%>
33
- <%= route[:key] %>: Object.assign(
34
- (params, options) => ({
35
- url: buildUrl('<%= route[:path] %>', params, options),
36
- method: '<%= route[:verb] %>',
37
- }),
38
- { form: (params, options) =>
39
- formAction(buildUrl('<%= route[:path] %>', params, options), '<%= route[:verb] %>') },
40
- ),
41
- <%- end -%>
42
- <%- else -%>
43
- <%- if ts -%>
44
- <%= route[:key] %>: Object.assign(
45
- (options?: RouteOptions): RouteDefinition<<%= "'#{route[:verb]}'" %>> => ({
46
- url: buildUrl('<%= route[:path] %>', {}, options),
47
- method: '<%= route[:verb] %>',
48
- }),
49
- { form: (options?: RouteOptions): FormDefinition =>
50
- formAction(buildUrl('<%= route[:path] %>', {}, options), '<%= route[:verb] %>') },
51
- ),
52
- <%- end -%>
53
- <%- end -%>
54
- <%- elsif has_params -%>
55
14
  <%- if ts -%>
56
15
  <%= route[:key] %>: (
57
16
  params: { <%= parts.join('; ') %> }<%= " | string | number" if route[:single_required] %>,
@@ -20,14 +20,6 @@ export function addUrlDefault(key, value) {
20
20
  }
21
21
  }
22
22
 
23
- export function formAction(url, method) {
24
- if (method === 'get' || method === 'post') {
25
- return { action: url, method }
26
- }
27
- const sep = url.includes('?') ? '&' : '?'
28
- return { action: `${url}${sep}_method=${method.toUpperCase()}`, method: 'post' }
29
- }
30
-
31
23
  export function buildUrl(
32
24
  template,
33
25
  params,
@@ -5,11 +5,6 @@ export type RouteDefinition<M extends Method> = {
5
5
  method: M
6
6
  }
7
7
 
8
- export type FormDefinition = {
9
- action: string
10
- method: 'get' | 'post'
11
- }
12
-
13
8
  export type RouteOptions = {
14
9
  query?: Record<string, unknown>
15
10
  anchor?: string
@@ -37,14 +32,6 @@ export function addUrlDefault(key: string, value: unknown): void {
37
32
  }
38
33
  }
39
34
 
40
- export function formAction(url: string, method: Method): FormDefinition {
41
- if (method === 'get' || method === 'post') {
42
- return { action: url, method }
43
- }
44
- const sep = url.includes('?') ? '&' : '?'
45
- return { action: `${url}${sep}_method=${method.toUpperCase()}`, method: 'post' }
46
- }
47
-
48
35
  export function buildUrl(
49
36
  template: string,
50
37
  params: Record<string, unknown> | string | number,
@@ -15,19 +15,27 @@ module Typelizer
15
15
  end
16
16
  end
17
17
 
18
+ def transform_properties(props)
19
+ return props unless config.properties_transformer
20
+
21
+ props = config.properties_transformer.call(props)
22
+ props.map do |prop|
23
+ next prop unless prop.type.is_a?(Shape)
24
+
25
+ prop.with(type: Shape.new(properties: transform_properties(prop.type.properties)))
26
+ end
27
+ end
28
+
18
29
  def infer_nested_property_types(prop)
19
- return prop unless prop.nested_properties&.any?
30
+ return prop unless prop.type.is_a?(Shape)
20
31
 
21
- typelizes = prop.nested_typelizes || {}
22
- inferred = prop.nested_properties.map do |sub_prop|
23
- dsl_type = typelizes[sub_prop.column_name.to_sym] || typelizes[sub_prop.name.to_sym]
32
+ inferred = prop.type.map_properties do |sub_prop|
24
33
  sub_prop
25
- .then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
34
+ .then { |p| p.type ? p : apply_model_inference(p) }
26
35
  .then { |p| apply_metadata(p) }
27
36
  .then { |p| infer_nested_property_types(p) }
28
37
  end
29
-
30
- prop.with(nested_properties: inferred)
38
+ prop.with(type: inferred)
31
39
  end
32
40
 
33
41
  def model_class
@@ -23,6 +23,7 @@ module Typelizer
23
23
 
24
24
  def parse(type_def, **options)
25
25
  return options if type_def.nil?
26
+ return parse_shape(type_def, **options) if type_def.is_a?(Hash)
26
27
  return parse_array(type_def, **options) if type_def.is_a?(Array)
27
28
 
28
29
  type_str = type_def.to_s
@@ -49,8 +50,35 @@ module Typelizer
49
50
  type_str.end_with?("?", "[]")
50
51
  end
51
52
 
53
+ # Strips a trailing `?` from an attribute key, returning [clean_name, optional?].
54
+ def parse_key(name)
55
+ str = name.to_s
56
+ str.end_with?("?") ? [str.chomp("?").to_sym, true] : [name.to_sym, false]
57
+ end
58
+
59
+ def apply_optional_key(parsed, optional_from_key)
60
+ parsed[:optional] = true if optional_from_key && !parsed.key?(:optional)
61
+ parsed
62
+ end
63
+
52
64
  private
53
65
 
66
+ def parse_shape(hash, **options)
67
+ properties = hash.map do |name, value|
68
+ clean_name, optional_from_key = parse_key(name)
69
+
70
+ # parse_declaration returns Hash args verbatim (options-bag form); nested
71
+ # shapes need parse to dispatch back here and build a Shape.
72
+ parsed = value.is_a?(Hash) ? parse(value) : parse_declaration(value)
73
+ apply_optional_key(parsed, optional_from_key)
74
+
75
+ property_attrs = parsed.slice(*Property.members).tap { |h| h[:name] = clean_name }
76
+ Property.new(optional: false, nullable: false, multi: false, **property_attrs)
77
+ end
78
+
79
+ {type: Shape.new(properties: properties)}.merge(options)
80
+ end
81
+
54
82
  def parse_array(type_defs, **options)
55
83
  raise ArgumentError, "Empty array passed to typelize" if type_defs.empty?
56
84
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typelizer
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -60,9 +60,9 @@ module Typelizer
60
60
  end
61
61
 
62
62
  def write_enums(enums)
63
- fingerprint = [enums.map(&:fingerprint), config.properties_sort_order, config.prefer_double_quotes].inspect
63
+ fingerprint = [enums.map(&:fingerprint), config.properties_sort_order, config.prefer_double_quotes, config.enum_runtime].inspect
64
64
  write_file("Enums.ts", fingerprint) do
65
- render_template("enums.ts.erb", enums: enums, sort_order: config.properties_sort_order, prefer_double_quotes: config.prefer_double_quotes)
65
+ render_template("enums.ts.erb", enums: enums, sort_order: config.properties_sort_order, prefer_double_quotes: config.prefer_double_quotes, enum_runtime: config.enum_runtime)
66
66
  end
67
67
  end
68
68
 
@@ -74,7 +74,7 @@ module Typelizer
74
74
  }
75
75
  ].inspect
76
76
  write_file("index.ts", fingerprint) do
77
- render_template("index.ts.erb", interfaces: interfaces, enums: enums, index_dir: config.output_dir.to_s, imports_sort_order: config.imports_sort_order, prefer_double_quotes: config.prefer_double_quotes)
77
+ render_template("index.ts.erb", interfaces: interfaces, enums: enums, index_dir: config.output_dir.to_s, imports_sort_order: config.imports_sort_order, prefer_double_quotes: config.prefer_double_quotes, enum_runtime: config.enum_runtime)
78
78
  end
79
79
  end
80
80
 
data/lib/typelizer.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "typelizer/version"
4
4
  require_relative "typelizer/union_type_sorter"
5
+ require_relative "typelizer/shape"
5
6
  require_relative "typelizer/property"
6
7
  require_relative "typelizer/model_plugins/auto"
7
8
  require_relative "typelizer/serializer_plugins/auto"
@@ -50,10 +51,7 @@ module Typelizer
50
51
  # Is Typelizer active?
51
52
  #
52
53
  # Precedence: TYPELIZER env var > development? detection
53
- # Legacy DISABLE_TYPELIZER is mapped to TYPELIZER with a deprecation warning.
54
54
  def enabled?
55
- migrate_legacy_env!
56
-
57
55
  val = ENV["TYPELIZER"]
58
56
  return val == "true" || val == "1" if val
59
57
 
@@ -95,23 +93,9 @@ module Typelizer
95
93
  private
96
94
 
97
95
  def development?
98
- ENV["RAILS_ENV"] == "development" || ENV["RACK_ENV"] == "development"
99
- end
96
+ return Rails.env.development? if defined?(Rails) && Rails.respond_to?(:env)
100
97
 
101
- # Maps legacy DISABLE_TYPELIZER to TYPELIZER with a deprecation warning.
102
- # Only takes effect if TYPELIZER is not already set.
103
- def migrate_legacy_env!
104
- return if @legacy_env_migrated
105
- @legacy_env_migrated = true
106
-
107
- val = ENV["DISABLE_TYPELIZER"]
108
- return unless val
109
-
110
- new_val = (val == "true" || val == "1") ? "false" : "true"
111
- logger.warn(
112
- "[Typelizer] DISABLE_TYPELIZER is deprecated, use TYPELIZER=#{new_val} instead."
113
- )
114
- ENV["TYPELIZER"] ||= new_val
98
+ ENV["RAILS_ENV"] == "development" || ENV["RACK_ENV"] == "development"
115
99
  end
116
100
 
117
101
  def load_serializers
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typelizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 6.0.0
18
+ version: 6.1.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 6.0.0
25
+ version: 6.1.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: benchmark
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -62,6 +62,7 @@ files:
62
62
  - lib/typelizer/import_sorter.rb
63
63
  - lib/typelizer/interface.rb
64
64
  - lib/typelizer/listen.rb
65
+ - lib/typelizer/middleware.rb
65
66
  - lib/typelizer/model_plugins/active_record.rb
66
67
  - lib/typelizer/model_plugins/auto.rb
67
68
  - lib/typelizer/model_plugins/poro.rb
@@ -82,6 +83,7 @@ files:
82
83
  - lib/typelizer/serializer_plugins/base.rb
83
84
  - lib/typelizer/serializer_plugins/oj_serializers.rb
84
85
  - lib/typelizer/serializer_plugins/panko.rb
86
+ - lib/typelizer/shape.rb
85
87
  - lib/typelizer/templates/comment.ts.erb
86
88
  - lib/typelizer/templates/enums.ts.erb
87
89
  - lib/typelizer/templates/fingerprint.erb