Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/typelizer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "typelizer/version"
require_relative "typelizer/union_type_sorter"
require_relative "typelizer/property"
require_relative "typelizer/model_plugins/auto"
require_relative "typelizer/serializer_plugins/auto"
Expand Down
2 changes: 1 addition & 1 deletion lib/typelizer/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def inline?

def name
if inline?
Renderer.call("inline_type.ts.erb", properties: properties).strip
Renderer.call("inline_type.ts.erb", properties: properties, sort_order: config.properties_sort_order).strip
else
config.serializer_name_mapper.call(serializer).tr_s(":", "")
end
Expand Down
31 changes: 28 additions & 3 deletions lib/typelizer/property.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ def eql?(other)
fingerprint == other.fingerprint
end

# Default to_s for backward compatibility (no sorting)
def to_s
type_str = type_name
render(sort_order: :none)
end

# Renders the property as a TypeScript property string
# @param sort_order [Symbol, Proc, nil] Sort order for union types (:none, :alphabetical, or Proc)
# @return [String] The property string like "name?: Type1 | Type2"
def render(sort_order: :none)
type_str = type_name(sort_order: sort_order)

# Handle intersection types for traits
if with_traits&.any? && type.respond_to?(:name)
Expand All @@ -26,14 +34,20 @@ def to_s
end

type_str = "Array<#{type_str}>" if multi

# Apply union sorting to the final type string (handles Array<...> unions too)
type_str = UnionTypeSorter.sort(type_str, sort_order)

# Add nullable at the end (null should always be last in sorted output)
type_str = "#{type_str} | null" if nullable

"#{name}#{"?" if optional}: #{type_str}"
end

def fingerprint
props = to_h
props[:type] = type_name
# Always use alphabetical sorting in fingerprint for deterministic change detection
props[:type] = UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical)
props.each_with_object(+"<#{self.class.name}") do |(k, v), fp|
fp << " #{k}=#{v.inspect}" unless v.nil?
end << ">"
Expand All @@ -47,9 +61,20 @@ def enum_definition

private

def type_name
# Returns the type name, optionally sorting union members
# @param sort_order [Symbol, Proc, nil] Sort order for union types
# @return [String] The type name
def type_name(sort_order: :none)
# If enum_type_name is set, use it (named enum type)
return enum_type_name if enum_type_name

if enum
# Sort enum values if alphabetical sorting is requested
enum_values = enum.map { |v| v.to_s.inspect }
enum_values = enum_values.sort_by(&:downcase) if sort_order == :alphabetical
return enum_values.join(" | ")
end

type.respond_to?(:name) ? type.name : type || "unknown"
end
end
Expand Down
6 changes: 5 additions & 1 deletion lib/typelizer/templates/inheritance.ts.erb
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
<%= interface.overwritten_properties.any? ? "Omit<" : "" %><%= interface.parent_interface.name %><%= "[" + interface.quote(interface.parent_interface.root_key) + "]" if interface.parent_interface.root_key %><%= interface.overwritten_properties.any? ? ", " + interface.overwritten_properties.map { |pr| interface.quote(pr.name) }.join(' | ') + ">" : ""%>
<%
omit_props = interface.overwritten_properties.map { |pr| interface.quote(pr.name) }
omit_props = omit_props.sort_by(&:downcase) if interface.config.properties_sort_order == :alphabetical
-%>
<%= interface.overwritten_properties.any? ? "Omit<" : "" %><%= interface.parent_interface.name %><%= "[" + interface.quote(interface.parent_interface.root_key) + "]" if interface.parent_interface.root_key %><%= interface.overwritten_properties.any? ? ", " + omit_props.join(' | ') + ">" : ""%>
2 changes: 1 addition & 1 deletion lib/typelizer/templates/inline_type.ts.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
<%- properties.each do |property| -%>
<%= indent(property) %>;
<%= indent(property.render(sort_order: sort_order || :none)) %>;
<%- end -%>
}
6 changes: 3 additions & 3 deletions lib/typelizer/templates/interface.ts.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,22 @@ render("inheritance.ts.erb", interface: interface).strip if interface.parent_int
<%= " & " if interface.parent_interface %>{
<% interface.properties_to_print.each do |property| -%>
<%= render("comment.ts.erb", interface: interface, property: property) -%>
<%= indent(property) %>;
<%= indent(property.render(sort_order: interface.config.properties_sort_order)) %>;
<% end -%>
}
<% end %><% if interface.root_key %>
type <%= interface.name %> = {
<%= indent(interface.root_key) %>: <%= interface.name %>Data;
<% interface.meta_fields&.each do |property| -%>
<%= indent(property) %>;
<%= indent(property.render(sort_order: interface.config.properties_sort_order)) %>;
<% end -%>
}
<% end -%>
<% interface.trait_interfaces.each do |trait| -%>

type <%= trait.name %> = {
<% trait.properties.each do |property| -%>
<%= indent(property) %>;
<%= indent(property.render(sort_order: interface.config.properties_sort_order)) %>;
<% end -%>
}
<% end -%>
Expand Down
148 changes: 148 additions & 0 deletions lib/typelizer/union_type_sorter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# frozen_string_literal: true

module Typelizer
# Sorts union type members within TypeScript type strings.
# Handles types like "Type3 | Type1 | Type2" -> "Type1 | Type2 | Type3"
# Also handles complex nested types like "Array<Type3 | Type1>" -> "Array<Type1 | Type3>"
module UnionTypeSorter
# Sorts union type members in a type string
# @param type_str [String] The type string potentially containing unions
# @param sort_order [Symbol, Proc, nil] The sort order (:none, :alphabetical, or Proc)
# @return [String] The type string with sorted union members
def self.sort(type_str, sort_order)
return type_str if type_str.nil? || type_str.empty?

case sort_order
when :none, nil
type_str
when :alphabetical
sort_unions_alphabetically(type_str)
when Proc
result = sort_order.call(type_str)
result.is_a?(String) ? result : type_str
else
type_str
end
rescue => e
Typelizer.logger.warn("UnionTypeSorter error: #{e.message}, preserving original order")
type_str
end

# Sorts union members alphabetically while preserving structure
# @param type_str [String] The type string to sort
# @return [String] The sorted type string
def self.sort_unions_alphabetically(type_str)
# Handle the string by sorting unions at each level
# We need to be careful with nested generics like Array<A | B | C>

result = type_str.dup

# First, handle unions inside angle brackets (generics)
# Match content inside < > and sort unions within
result = result.gsub(/<([^<>]+)>/) do |match|
inner = Regexp.last_match(1)
sorted_inner = sort_simple_union(inner)
"<#{sorted_inner}>"
end

# Then handle any remaining top-level unions
# But avoid sorting if the string has unbalanced brackets
if balanced_brackets?(result)
result = sort_top_level_union(result)
end

result
end

# Sorts a simple union string (no nested generics)
# @param union_str [String] String like "Type3 | Type1 | Type2"
# @return [String] Sorted string like "Type1 | Type2 | Type3"
def self.sort_simple_union(union_str)
return union_str unless union_str.include?("|")

parts = split_union_members(union_str)
return union_str if parts.size <= 1

# Sort while preserving special cases:
# - 'null' should typically stay at the end
# - Keep the relative order of complex nested types
regular_parts, null_parts = parts.partition { |p| p.strip.downcase != "null" }

sorted_regular = regular_parts.sort_by { |p| p.strip.downcase }
(sorted_regular + null_parts).join(" | ")
end

# Sorts top-level union (handles cases where unions aren't inside generics)
# @param type_str [String] The type string
# @return [String] The sorted type string
def self.sort_top_level_union(type_str)
return type_str unless type_str.include?("|")

parts = split_union_members(type_str)
return type_str if parts.size <= 1

# Separate null from other types
regular_parts, null_parts = parts.partition { |p| p.strip.downcase != "null" }

sorted_regular = regular_parts.sort_by { |p| p.strip.downcase }
(sorted_regular + null_parts).join(" | ")
end

# Splits union members while respecting nested brackets
# @param str [String] The string to split
# @return [Array<String>] Array of union members
def self.split_union_members(str)
members = []
current = +""
depth = 0

str.each_char do |char|
case char
when "<", "("
depth += 1
current << char
when ">", ")"
depth -= 1
current << char
when "|"
if depth == 0
members << current.strip unless current.strip.empty?
current = +""
else
current << char
end
else
current << char
end
end

members << current.strip unless current.strip.empty?
members
end

# Checks if brackets are balanced in the string
# @param str [String] The string to check
# @return [Boolean] True if brackets are balanced
def self.balanced_brackets?(str)
angle_depth = 0
paren_depth = 0

str.each_char do |char|
case char
when "<"
angle_depth += 1
when ">"
angle_depth -= 1
return false if angle_depth < 0
when "("
paren_depth += 1
when ")"
paren_depth -= 1
return false if paren_depth < 0
end
end

angle_depth == 0 && paren_depth == 0
end
end
end
2 changes: 1 addition & 1 deletion spec/__snapshots__/AlbaInline.ts.snap
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Typelizer digest 1f9c0aa4990a456612d866b504d0a8ff
// Typelizer digest 1f176602b8eabd48a6361f57494ad27b
//
// DO NOT MODIFY: This file was automatically generated by Typelizer.

Expand Down
16 changes: 16 additions & 0 deletions spec/__snapshots__/AlbaUnionSorted.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Typelizer digest ac62105fdebc3de074783f8e9c48f433
//
// DO NOT MODIFY: This file was automatically generated by Typelizer.
import type {TypeZ, TypeA, TypeM, ZebraSection, AlphaSection, BetaSection} from '@/types'

type AlbaUnionSorted = {
email: string;
/** Unique identifier */
id: string;
items: Array<TypeA | TypeM | TypeZ>;
sections: AlphaSection | BetaSection | ZebraSection;
status: "apple" | "banana" | "zebra";
username: string;
}

export default AlbaUnionSorted;
3 changes: 2 additions & 1 deletion spec/__snapshots__/index.ts.snap
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Typelizer digest d9b18b2c19a0cbf4a2b434d272c4ee31
// Typelizer digest 3e213a66271da1e65c0e745f00324727
//
// DO NOT MODIFY: This file was automatically generated by Typelizer.
export type { PostCategory, UserRole } from './Enums'
Expand Down Expand Up @@ -26,6 +26,7 @@ export type { default as AlbaTraitsAssociations, AlbaTraitsAssociationsAssociati
export type { default as AlbaTraits, AlbaTraitsBasicTrait, AlbaTraitsTimeRelatedTrait, AlbaTraitsComplexTrait, AlbaTraitsCustomAttributesTrait, AlbaTraitsWithOptionsTrait, AlbaTraitsMixedTrait, AlbaTraitsEmptyTrait } from './AlbaTraits'
export type { default as AlbaTransformKeys } from './AlbaTransformKeys'
export type { default as AlbaTypeShortcuts, AlbaTypeShortcutsWithMetadataTrait } from './AlbaTypeShortcuts'
export type { default as AlbaUnionSorted } from './AlbaUnionSorted'
export type { default as AlbaUserAuthor } from './AlbaUserAuthor'
export type { default as AlbaUserEmptyNested } from './AlbaUserEmptyNested'
export type { default as AlbaUser } from './AlbaUser'
Expand Down
37 changes: 37 additions & 0 deletions spec/app/app/serializers/alba/union_sorted_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

module Alba
class UnionSortedSerializer < BaseSerializer
typelizer_config do |c|
c.properties_sort_order = :alphabetical
end

typelize_from ::User

# Union type with multiple types - should be sorted alphabetically
typelize sections: ["ZebraSection", "AlphaSection", "BetaSection"]
attribute :sections do |user|
[]
end

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

# Enum values - should be sorted alphabetically
typelize status: [:string, enum: %w[zebra apple banana]]
attribute :status do |user|
"active"
end

# Regular properties to test property sorting still works
attributes :id, :username

typelize email: :string
attribute :email do |user|
"test@example.com"
end
end
end
Loading