Skip to content

Commit 4a55a39

Browse files
committed
Treat strings as literal types in array declarations
1 parent b4f422a commit 4a55a39

File tree

15 files changed

+161
-77
lines changed

15 files changed

+161
-77
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning].
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **Breaking:** Arrays of strings in `typelize` now produce string literal unions (`'active' | 'inactive'`) instead of type reference unions. Use symbols for type references: `typelize status: [:string, :number]`. ([@skryukov])
13+
1014
## [0.9.3] - 2026-02-27
1115

1216
### Fixed

README.md

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,10 @@ class PostResource < ApplicationResource
119119
typelize categories: "string?[]" # optional array of strings (categories?: Array<string>)
120120

121121
# Shortcuts can be combined with explicit options
122-
typelize status: ["string?", nullable: true] # optional and nullable
122+
typelize status: [:string?, nullable: true] # optional and nullable
123123

124124
# Also works with keyless typelize
125-
typelize "string?"
125+
typelize :string?
126126
attribute :nickname do |user|
127127
user.nickname
128128
end
@@ -167,8 +167,8 @@ class PostResource < ApplicationResource
167167
typelize target: "UserResource | CommentResource"
168168
attribute :target
169169

170-
# String and class constant can be mixed
171-
typelize item: ["Namespace::UserResource", CommentResource]
170+
# Pipe-delimited string with namespaced serializer
171+
typelize item: "Namespace::UserResource | CommentResource"
172172
attribute :item
173173
end
174174
```
@@ -183,8 +183,8 @@ class PostResource < ApplicationResource
183183
typelize content: "TextBlock | ImageBlock"
184184
attribute :content
185185

186-
# Works with arrays too
187-
typelize sections: ["TextBlock", "ImageBlock"]
186+
# Works with arrays of symbols too
187+
typelize sections: [:TextBlock, :ImageBlock]
188188
attribute :sections
189189
end
190190
```
@@ -200,10 +200,39 @@ type Post = {
200200
}
201201
```
202202
203+
String arrays are treated as string literal unions — useful for enums and state machines:
204+
205+
```ruby
206+
class PostResource < ApplicationResource
207+
attributes :id, :title
208+
209+
# Array of stringsgenerates string literal union type
210+
typelize status: ["draft", "published", "archived"]
211+
attribute :status
212+
213+
# Works with Rails enums and state machines
214+
typelize review_state: ReviewStateMachine.states.keys
215+
attribute :review_state
216+
end
217+
```
218+
219+
This generates:
220+
221+
```typescript
222+
type Post = {
223+
id: number;
224+
title: string;
225+
status: 'draft' | 'published' | 'archived';
226+
review_state: 'pending' | 'approved' | 'rejected';
227+
}
228+
```
229+
230+
> **Note:** In arrays, **strings** become string literal types (`'a'`), while **symbols** and **class constants** become type references (`A`). You can mix them: `[:number, "auto"]` produces `number | 'auto'`.
231+
203232
For more complex type definitions, use the full API:
204233
205234
```ruby
206-
typelize attribute_name: ["string", "Date", optional: true, nullable: true, multi: true, enum: %w[foo bar], comment: "Attribute description", deprecated: "Use `another_attribute` instead"]
235+
typelize attribute_name: [:string, :Date, optional: true, nullable: true, multi: true, enum: %w[foo bar], comment: "Attribute description", deprecated: "Use `another_attribute` instead"]
207236
```
208237
209238
### Alba Traits

lib/typelizer/dsl.rb

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ def assign_type_information(attribute_name, attributes)
8787
attributes.each do |name, attrs|
8888
next unless name
8989

90-
options = parse_type_declaration(attrs)
91-
store_type(attribute_name, name, options)
90+
store_type(attribute_name, name, TypeParser.parse_declaration(attrs))
9291
end
9392
end
9493

@@ -112,26 +111,6 @@ def ensure_type_store(attribute_name)
112111
end
113112
end
114113
end
115-
116-
def parse_type_declaration(attrs)
117-
attrs = [attrs] if attrs && !attrs.is_a?(Array)
118-
options = attrs.last.is_a?(Hash) ? attrs.pop : {}
119-
120-
if attrs.any?
121-
parsed_types = attrs.map { |t| TypeParser.parse(t) }
122-
all_types = parsed_types.flat_map { |p| Array(p[:type]) }
123-
parsed_types.each do |parsed|
124-
options[:optional] = true if parsed[:optional]
125-
options[:multi] = true if parsed[:multi]
126-
options[:nullable] = true if parsed[:nullable]
127-
end
128-
options[:nullable] = true if all_types.delete(:null)
129-
# Unwrap single-element arrays: typelize field: ["string"] behaves like typelize field: "string"
130-
options[:type] = (all_types.size == 1) ? all_types.first : all_types
131-
end
132-
133-
options
134-
end
135114
end
136115
end
137116
end

lib/typelizer/serializer_plugins/alba/block_attribute_collector.rb

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,7 @@ def respond_to_missing?(method_name, include_private = false)
111111
private
112112

113113
def normalize_typelize(type_def, **options)
114-
case type_def
115-
when Array
116-
# [:string, nullable: true] or ['string?', nullable: true]
117-
type, *rest = type_def
118-
opts = rest.first || {}
119-
TypeParser.parse(type, **opts)
120-
when Symbol, String
121-
TypeParser.parse(type_def, **options)
122-
else
123-
options
124-
end
114+
TypeParser.parse_declaration(type_def, **options)
125115
end
126116
end
127117
end

lib/typelizer/type_parser.rb

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ module TypeParser
1010
TYPE_PATTERN = /\A(.+?)(\?)?(\[\])?(\?)?\z/
1111

1212
class << self
13+
def parse_declaration(attrs, **options)
14+
return options.merge(attrs) if attrs.is_a?(Hash)
15+
return parse(attrs, **options) unless attrs.is_a?(Array)
16+
17+
options = attrs.last.merge(options) if attrs.last.is_a?(Hash)
18+
types = attrs.reject { |t| t.is_a?(Hash) }
19+
return options if types.empty?
20+
21+
parse((types.size == 1) ? types.first : types, **options)
22+
end
23+
1324
def parse(type_def, **options)
1425
return options if type_def.nil?
1526
return parse_array(type_def, **options) if type_def.is_a?(Array)
@@ -41,22 +52,28 @@ def shortcut?(type_def)
4152
private
4253

4354
def parse_array(type_defs, **options)
44-
parsed = type_defs.map { |t| parse(t) }
45-
types = parsed.flat_map { |p| Array(p[:type]) }
46-
47-
parsed.each do |p|
48-
options[:optional] = true if p[:optional]
49-
options[:multi] = true if p[:multi]
50-
options[:nullable] = true if p[:nullable]
55+
raise ArgumentError, "Empty array passed to typelize" if type_defs.empty?
56+
57+
types = []
58+
type_defs.each do |t|
59+
if t.is_a?(String)
60+
types << :"'#{t}'"
61+
else
62+
parsed = parse(t)
63+
types.concat(Array(parsed[:type]))
64+
options[:optional] = true if parsed[:optional]
65+
options[:multi] = true if parsed[:multi]
66+
options[:nullable] = true if parsed[:nullable]
67+
end
5168
end
5269

5370
options[:nullable] = true if types.delete(:null)
71+
wrap_type(types, **options)
72+
end
5473

55-
if types.size == 1
56-
{type: types.first}.merge(options)
57-
else
58-
{type: types}.merge(options)
59-
end
74+
def wrap_type(types, **options)
75+
type = (types.size == 1) ? types.first : types
76+
{type: type}.merge(options)
6077
end
6178

6279
def parse_union(type_str, **options)

spec/app/app/serializers/alba/class_ref_serializer.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ class ClassRefSerializer < BaseSerializer
2020

2121
# Test: typelize with union of two serializer classes generates anyOf in OpenAPI
2222
attributes :commentable
23-
typelize commentable: ["Alba::UserSerializer", "Alba::CommentSerializer"]
23+
typelize commentable: "Alba::UserSerializer | Alba::CommentSerializer"
2424

25-
# Test: typelize with mixed string and class constant in union
25+
# Test: typelize with mixed class constants in union
2626
attributes :mixed_ref
27-
typelize mixed_ref: ["Alba::UserSerializer", CommentSerializer]
27+
typelize mixed_ref: [UserSerializer, CommentSerializer]
2828

2929
# Test: nullable array of refs — nullable applies to array, not items
3030
attributes :contributors

spec/app/app/serializers/alba/custom_types_serializer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class CustomTypesSerializer < BaseSerializer
6363
end
6464

6565
# Keyless array typelize (union from array)
66-
typelize ["string", "number"]
66+
typelize [:string, :number]
6767

6868
attribute :tag do |user|
6969
"important"

spec/app/app/serializers/alba/union_sorted_serializer.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ class UnionSortedSerializer < BaseSerializer
99
typelize_from ::User
1010

1111
# Union type with multiple types - should be sorted alphabetically
12-
typelize sections: ["ZebraSection", "AlphaSection", "BetaSection"]
12+
typelize sections: [:ZebraSection, :AlphaSection, :BetaSection]
1313
attribute :sections do |user|
1414
[]
1515
end
1616

1717
# Union type in an array - should be sorted inside Array<>
18-
typelize items: ["TypeZ", "TypeA", "TypeM", multi: true]
18+
typelize items: [:TypeZ, :TypeA, :TypeM, multi: true]
1919
attribute :items do |user|
2020
[]
2121
end

spec/app/app/serializers/ams/class_ref_serializer.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ class ClassRefSerializer < BaseSerializer
2020

2121
# Test: typelize with union of two serializer classes generates anyOf in OpenAPI
2222
attribute :commentable
23-
typelize commentable: ["Ams::UserSerializer", "Ams::CommentSerializer"]
23+
typelize commentable: "Ams::UserSerializer | Ams::CommentSerializer"
2424

25-
# Test: typelize with mixed string and class constant in union
25+
# Test: typelize with mixed class constants in union
2626
attribute :mixed_ref
27-
typelize mixed_ref: ["Ams::UserSerializer", CommentSerializer]
27+
typelize mixed_ref: [UserSerializer, CommentSerializer]
2828

2929
# Test: nullable array of refs — nullable applies to array, not items
3030
attribute :contributors

spec/app/app/serializers/ams/custom_types_serializer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class CustomTypesSerializer < BaseSerializer
3030
typelize kind: "'user' | null"
3131
attribute :kind
3232

33-
typelize ["string", "number"]
33+
typelize [:string, :number]
3434
attribute :tag
3535
end
3636
end

0 commit comments

Comments
 (0)