sorbet-schema 0.10.0 → 0.11.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: b0c3bb89fee8f842f919e0cf1f53d8f97fb12e4464effb88c7e821134c531a1f
4
- data.tar.gz: a78684855bb1bd76e6688b0daa04f026fcb55c23fd2f354daaea8e7a2f0f5cd3
3
+ metadata.gz: 8ae3eaa249b0881483d4fe8edd8c295ada54733bb78d106525767506e83f20bb
4
+ data.tar.gz: f36595f7d0831a83234453acccb941dc3bcff703562a87a52dbb1b2dbfd0dce0
5
5
  SHA512:
6
- metadata.gz: 28656d268e7861ae7e069ff30b024a2d063ee33f5824ca9bc1319cbe91555b2e80b402ee2438b975d299c58f624c4e8afba1ba8649a62c8d05a6efc3fa10fbac
7
- data.tar.gz: 9faf4f7458d56d9a66645d1d25aa4d70a83eea9abba3e1021a5fe58a5978d0c309a987d20e5af1dfc538f5d0a550f40ed1afa6a35c39ff2e5173a9b44a6cd118
6
+ metadata.gz: 1ff4723d4f1a749d3d2ee7255b4c024c783b10acf7701240889d9bc25b3682df9c308a6a525c6a055bb8b4a627ce8214d45dfda6c359b064f90beb43693fe5bc
7
+ data.tar.gz: f3352237a1b10e95747c313c4937869c2baac94e45cedd1eb29c3b550d5a7c41b731b07505fbb61d7645e7d46efec986d95a95ed2d3472bf9e65cac9403340bf
data/AGENTS.md CHANGED
@@ -4,7 +4,8 @@ This file is the project's committed home for project-intrinsic agent knowledge:
4
4
 
5
5
  - Add durable project-specific notes here as they are discovered through real work.
6
6
  - Ruby 3.4+ unbundles former stdlib gems (e.g. `csv`) from the default load path, so `require "csv"` is not free — it needs the gem actually installed.
7
- - Format-specific serializers (`CSVSerializer`, `MessagePackSerializer`, `ActiveRecordSerializer`) treat their backing gem as optional: no top-level `require`, gem is `add_development_dependency` only (not `add_runtime_dependency`), the `require`/gem-presence check happens lazily in `initialize` with a clear `ArgumentError` on failure, and `T::Struct.serializer`'s factory (`lib/sorbet-schema/t/struct.rb`) mirrors that same guard via `defined?(...)`. Follow this pattern for any new serializer/coercer with a non-stdlib dependency; see `lib/typed/csv_serializer.rb` for the exact shape.
7
+ - Format-specific serializers keep their backing gems optional: load the gem in `initialize`, raise a clear `ArgumentError` on `LoadError`, and keep class-body signatures safe to reflect without the gem installed. See `lib/typed/csv_serializer.rb` and `lib/typed/active_record_serializer.rb`; the latter uses a generic model output instead of referencing `ActiveRecord::Base` in signatures. Verify eager loading changes with `bundle exec tapioca gem` in a consumer bundle without `activerecord`, and keep the factory's missing-gem behavior consistent.
8
+ - Every `Typed::Serializer` subclass (all six format serializers) and `Typed::Schema` are generic over the struct type via a `StructT = type_member { {upper: T::InexactStruct} }` re-declared in each subclass — `T::InexactStruct` is Sorbet's common superclass for `T::Struct` and `T::ImmutableStruct`; Sorbet cannot propagate their union as a type-member bound. This lets every deserialize path (`deserialize_from`, `.serializer(:x).deserialize`, `.schema.from_x`, direct serializer construction) return the concrete struct type with no `T.cast`. `Typed::Schema#target` is `T.all(T::Class[StructT], StructClass)`, not the bare `T::Class[StructT]`: Sorbet runtime erases `T::Class[X]` to `T::Class[T.untyped]`, while the `StructClass` intersection restores the runtime restriction to `T::Struct`/`T::ImmutableStruct` descendants. See `lib/typed/serializer.rb`, `lib/typed/schema.rb`, and `lib/typed/hash_serializer.rb`'s `self.new` override (the one class that needs it, for direct-construction type inference) for the exact shape when adding a new serializer.
8
9
  - After adding a new *required* runtime gem dependency (not this optional-serializer case), run `bundle exec tapioca gem` (add the gem to `sorbet/tapioca/require.rb` first if it isn't required anywhere else) so `bundle exec rake` (which runs `srb tc`) has an RBI to check against.
9
10
 
10
11
  ## Maintaining this file
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.11.0](https://github.com/maxveldink/sorbet-schema/compare/v0.10.0...v0.11.0) (2026-10-11)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * **typed:** ActiveRecord serializer annotations now take the model type first and struct type second: `Typed::ActiveRecordSerializer[PersonModel, Person]`. Direct `.new(schema: Person.schema, model_class: PersonModel)` infers both types. Regenerate consumer gem RBIs after upgrading.
13
+ * **typed:** `Typed::Serializer::DeserializeResult` and `Typed::Coercion::StructCoercer::Target` now accept immutable as well as mutable structs. `DeserializeResult` is `Typed::Result[Typed::StructValue, Typed::DeserializeError]`, where `Typed::StructValue` is the union of `T::Struct` and `T::ImmutableStruct`. Update annotations that assume these aliases only contain `T::Struct`; use `Typed::StructValue` when handling both, or obtain a concrete struct result through a parameterized schema or serializer.
14
+ * **typed:** Props with a `name:` option now use that name for both input and output in Hash, JSON, CSV, YAML, and MessagePack. For `const :event_id, String, name: "eventId"`, migrate payload keys and CSV headers from `event_id` to `eventId`. The old key no longer populates the field: required fields fail validation, while optional fields may use their default or nil. ActiveRecord continues to use Ruby attribute names.
15
+ * **typed:** Schema and serializers now require a struct type argument in Sorbet annotations. Use `Typed::Schema[Person]`, `Typed::HashSerializer[Person]` (and the equivalent JSON, CSV, YML, and MessagePack types), and `Typed::Serializer[Input, Output, Person]` instead of the former unparameterized classes or two-argument base serializer. Custom serializer subclasses must redeclare `StructT = type_member { {upper: T::InexactStruct} }` and use it in their schema, serialize, and deserialize signatures (including `Typed::Schema[StructT]` and `Typed::Result[StructT, Typed::DeserializeError]`). Regenerate consumer gem RBIs and update custom `.schema` overrides to return a parameterized schema. Prefer `Schema.from_struct(Person)` or `Typed::Schema[Person].new(...)` to preserve concrete type inference.
16
+
17
+ ### Features
18
+
19
+ * **typed:** generate a JSON Schema from a schema ([#152](https://github.com/maxveldink/sorbet-schema/issues/152)) ([575ae72](https://github.com/maxveldink/sorbet-schema/commit/575ae72eabd7f0a3ade6299732128a586041b5bf))
20
+ * **typed:** honor the prop name: option as the serialized key ([08490d2](https://github.com/maxveldink/sorbet-schema/commit/08490d26c04385cd00a651b9e10506ad75ce960f))
21
+ * **typed:** make Serializer and Schema generic over struct type ([086e867](https://github.com/maxveldink/sorbet-schema/commit/086e8671aeda7868e6dde98b8d23621918059e4b))
22
+ * **typed:** support immutable nested JSON Schema ([#162](https://github.com/maxveldink/sorbet-schema/issues/162)) ([d74d6cd](https://github.com/maxveldink/sorbet-schema/commit/d74d6cdd63017a268f1ecd78f1e68314460767c9))
23
+ * **typed:** support immutable structs ([288c972](https://github.com/maxveldink/sorbet-schema/commit/288c972cd87cb194b19743d3eb5bb7c2e9f7f617))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **activerecord:** load the optional gem lazily and raise `ArgumentError` when unavailable, including direct construction that previously raised `NameError` ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
29
+ * **json:** return `Failure(ParseError)` for non-object JSON roots (arrays, scalars, and null); update callers that rescue exceptions for these inputs ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
30
+ * **schema:** preserve optionality, nullability, defaults, serialized names, and descriptions when updating a field with `add_serializer` ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
31
+ * **typed:** harden JSON parsing, field updates, and ActiveRecord loading ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
32
+
7
33
  ## [0.10.0](https://github.com/maxveldink/sorbet-schema/compare/v0.9.3...v0.10.0) (2026-08-07)
8
34
 
9
35
 
data/Gemfile CHANGED
@@ -12,7 +12,7 @@ group :development do
12
12
  gem "standard-performance"
13
13
  gem "standard-sorbet"
14
14
  gem "sorbet"
15
- gem "tapioca", "~> 0.16.6", require: false
15
+ gem "tapioca", "~> 0.20", require: false
16
16
  end
17
17
 
18
18
  group :development, :test do
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sorbet-schema (0.10.0)
4
+ sorbet-schema (0.11.0)
5
5
  sorbet-result (~> 1.1)
6
6
  sorbet-runtime (~> 0.5)
7
7
  sorbet-struct-comparable (~> 1.3)
@@ -32,6 +32,7 @@ GEM
32
32
  ansi (1.5.0)
33
33
  ast (2.4.2)
34
34
  base64 (0.3.0)
35
+ benchmark (0.5.0)
35
36
  benchmark-ips (2.14.0)
36
37
  bigdecimal (3.1.8)
37
38
  builder (3.3.0)
@@ -42,7 +43,7 @@ GEM
42
43
  irb (~> 1.10)
43
44
  reline (>= 0.3.8)
44
45
  drb (2.2.3)
45
- erubi (1.13.0)
46
+ erubi (1.13.1)
46
47
  i18n (1.14.8)
47
48
  concurrent-ruby (~> 1.0)
48
49
  io-console (0.7.2)
@@ -53,7 +54,7 @@ GEM
53
54
  language_server-protocol (3.17.0.3)
54
55
  lint_roller (1.1.0)
55
56
  logger (1.7.0)
56
- minitest (5.24.1)
57
+ minitest (5.27.0)
57
58
  minitest-focus (1.4.0)
58
59
  minitest (>= 4, < 6)
59
60
  minitest-reporters (1.7.1)
@@ -63,26 +64,29 @@ GEM
63
64
  ruby-progressbar
64
65
  msgpack (1.8.4)
65
66
  netrc (0.11.0)
66
- parallel (1.25.1)
67
+ parallel (1.28.0)
67
68
  parser (3.3.3.0)
68
69
  ast (~> 2.4.1)
69
70
  racc
70
- prism (1.6.0)
71
+ prism (1.9.0)
71
72
  psych (5.1.2)
72
73
  stringio
73
74
  racc (1.8.0)
74
75
  rainbow (3.1.1)
75
76
  rake (13.2.1)
76
- rbi (0.3.7)
77
+ rbi (0.4.3)
77
78
  prism (~> 1.0)
78
- rbs (>= 3.4.4)
79
- rbs (3.9.5)
79
+ rbs (>= 4.0.1)
80
+ rbs (4.2.0)
80
81
  logger
82
+ prism (>= 1.6.0)
83
+ tsort
81
84
  rdoc (6.7.0)
82
85
  psych (>= 4.0.0)
83
86
  regexp_parser (2.9.2)
84
87
  reline (0.5.9)
85
88
  io-console (~> 0.5)
89
+ require-hooks (0.5.1)
86
90
  rexml (3.3.1)
87
91
  strscan
88
92
  rubocop (1.64.1)
@@ -104,22 +108,27 @@ GEM
104
108
  rubocop-sorbet (0.7.8)
105
109
  rubocop (>= 0.90.0)
106
110
  ruby-progressbar (1.13.0)
111
+ rubydex (0.4.1-arm64-darwin)
112
+ rubydex (0.4.1-x86_64-linux)
107
113
  securerandom (0.4.1)
108
- sorbet (0.5.11475)
109
- sorbet-static (= 0.5.11475)
114
+ sorbet (0.6.13543)
115
+ sorbet-static (= 0.6.13543)
110
116
  sorbet-result (1.1.0)
111
117
  sorbet-runtime (~> 0.5)
112
- sorbet-runtime (0.5.11475)
113
- sorbet-static (0.5.11475-universal-darwin)
114
- sorbet-static (0.5.11475-x86_64-linux)
115
- sorbet-static-and-runtime (0.5.11475)
116
- sorbet (= 0.5.11475)
117
- sorbet-runtime (= 0.5.11475)
118
+ sorbet-runtime (0.6.13543)
119
+ sorbet-static (0.6.13543-universal-darwin)
120
+ sorbet-static (0.6.13543-x86_64-linux)
121
+ sorbet-static-and-runtime (0.6.13543)
122
+ sorbet (= 0.6.13543)
123
+ sorbet-runtime (= 0.6.13543)
118
124
  sorbet-struct-comparable (1.3.0)
119
125
  sorbet-runtime (>= 0.5)
120
- spoom (1.3.3)
126
+ spoom (1.8.10)
121
127
  erubi (>= 1.10.0)
122
128
  prism (>= 0.28.0)
129
+ rbi (>= 0.4.2)
130
+ rbs (>= 4.0.0.dev.5)
131
+ rexml (>= 3.2.6)
123
132
  sorbet-static-and-runtime (>= 0.5.10187)
124
133
  thor (>= 0.19.2)
125
134
  sqlite3 (2.9.0-arm64-darwin)
@@ -141,25 +150,25 @@ GEM
141
150
  rubocop-sorbet (~> 0.7.0)
142
151
  stringio (3.1.1)
143
152
  strscan (3.1.0)
144
- tapioca (0.16.6)
153
+ tapioca (0.20.0)
154
+ benchmark
145
155
  bundler (>= 2.2.25)
146
156
  netrc (>= 0.11.0)
147
157
  parallel (>= 1.21.0)
148
- rbi (~> 0.2)
149
- sorbet-static-and-runtime (>= 0.5.11087)
150
- spoom (>= 1.2.0)
158
+ rbi (>= 0.4.1)
159
+ require-hooks (>= 0.2.2)
160
+ rubydex (>= 0.1.0.beta10)
161
+ sorbet-static-and-runtime (>= 0.6.12698)
162
+ spoom (>= 1.7.16)
151
163
  thor (>= 1.2.0)
152
- yard-sorbet
153
- thor (1.3.1)
164
+ tsort
165
+ thor (1.5.0)
154
166
  timeout (0.6.0)
167
+ tsort (0.2.0)
155
168
  tzinfo (2.0.6)
156
169
  concurrent-ruby (~> 1.0)
157
170
  unicode-display_width (2.5.0)
158
171
  uri (1.1.1)
159
- yard (0.9.36)
160
- yard-sorbet (0.9.0)
161
- sorbet-runtime
162
- yard
163
172
  zeitwerk (2.6.16)
164
173
 
165
174
  PLATFORMS
@@ -187,7 +196,7 @@ DEPENDENCIES
187
196
  standard
188
197
  standard-performance
189
198
  standard-sorbet
190
- tapioca (~> 0.16.6)
199
+ tapioca (~> 0.20)
191
200
 
192
201
  BUNDLED WITH
193
202
  2.7.2
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sorbet Schema
2
2
 
3
- Extendable serialization and deserialization to various formats for Sorbet `T::Struct`s.
3
+ Serialize and deserialize Sorbet `T::Struct` and `T::ImmutableStruct` values.
4
4
 
5
5
  ## Installation
6
6
 
@@ -14,11 +14,11 @@ If bundler is not being used to manage dependencies, install the gem by executin
14
14
 
15
15
  ## Usage
16
16
 
17
- Sorbet Schema is designed to be compatible with Sorbet's `T::Struct` class, and seeks to update many of the common pitfalls developers encountering when deserializing to and serializing from a `T::Struct`.
17
+ Sorbet Schema supports both `T::Struct` and `T::ImmutableStruct`, and addresses common pitfalls when deserializing and serializing either kind of struct.
18
18
 
19
19
  ### Getting Started
20
20
 
21
- While you can directly define a `Typed::Schema` to be used for your serialization needs, you'll typically use the provided helper class method to generate a `Schema` from an existing `T::Struct`.
21
+ While you can directly define a `Typed::Schema` to be used for your serialization needs, you'll typically use the provided helper class method to generate a `Schema` from an existing `T::Struct` or `T::ImmutableStruct`.
22
22
 
23
23
  ```ruby
24
24
  class Person < T::Struct
@@ -44,7 +44,7 @@ result = json_serializer.serialize(max)
44
44
  result.payload # == '{"name":"Max","age":29}'
45
45
  ```
46
46
 
47
- Alternatively, you can use the built-in helper methods added to `T::Struct`s to quickly use the built-in serializers.
47
+ Alternatively, use the built-in helper methods on either struct class to quickly use the built-in serializers.
48
48
 
49
49
  ```ruby
50
50
  result = Person.deserialize_from(:json, '{"name":"Max","age":29}')
@@ -81,6 +81,29 @@ result = json_serializer.deserialize('{"name":"Max","age":"29"}')
81
81
  max = result.payload # == Person.new(name: "Max", age: 29)
82
82
  ```
83
83
 
84
+ ### Immutable structs
85
+
86
+ Use `T::ImmutableStruct` when deserialization must create a frozen struct:
87
+
88
+ ```ruby
89
+ class ImmutablePerson < T::ImmutableStruct
90
+ const :name, String
91
+ const :age, Integer
92
+ end
93
+
94
+ result = ImmutablePerson.deserialize_from(:json, '{"name":"Ada","age":37}')
95
+ ada = result.payload
96
+ ada.frozen? # => true
97
+
98
+ ada.serialize_to(:json).payload # => '{"name":"Ada","age":37}'
99
+ ```
100
+
101
+ The helper, serializer factory, and schema APIs retain the concrete immutable
102
+ result type in Sorbet. Nested fields, array elements, and hash values declared
103
+ as immutable structs are constructed as frozen instances as well. Sorbet does
104
+ not recursively freeze strings, arrays, hashes, or mutable child structs.
105
+ `T::InexactStruct` and its other subclasses remain unsupported.
106
+
84
107
  ### Rails Example
85
108
 
86
109
  Here's an extended example of how Sorbet Schema can be combined with a normal Rails request to easily convert between formats.
@@ -119,7 +142,7 @@ See [Getting Started](#getting-started) for more information on how to use the J
119
142
 
120
143
  #### HashSerializer
121
144
 
122
- While not strictly serialization, converting `T::Struct`s to and from Ruby `Hash`es has traditionally had many pitfalls ([well-documented](https://sorbet.org/docs/tstruct#legacy-code-and-historical-context) in the Sorbet docs). The `Typed::HashSerializer` aims to address several common issues, while providing the same `Result` handling for invalid or missing data and coercion behavior.
145
+ While not strictly serialization, converting structs to and from Ruby `Hash`es has traditionally had many pitfalls ([well-documented](https://sorbet.org/docs/tstruct#legacy-code-and-historical-context) in the Sorbet docs). The `Typed::HashSerializer` supports both struct classes with the same `Result` handling for invalid or missing data and coercion behavior.
123
146
 
124
147
  To use it, simply instantiate and use it like the `JSONSerializer`:
125
148
 
@@ -149,7 +172,7 @@ result = csv_serializer.serialize(max)
149
172
  result.payload # == "name,age\nMax,29\n"
150
173
  ```
151
174
 
152
- CSV is a flat, row-based format, so nested `T::Struct`s, `Hash`es, and `Array`s cannot be represented as their own columns. Rather than writing a lossy representation into a cell that can never be parsed back correctly, `CSVSerializer#serialize` returns a `Typed::SerializeError` naming the offending field(s) when a struct has one. Prefer this serializer for schemas with scalar fields only, or use an `inline_serializer` (see [Inline Serializers](#inline-serializers)) to flatten a nested field into a scalar before serializing it to CSV.
175
+ CSV is a flat, row-based format, so nested structs, `Hash`es, and `Array`s cannot be represented as their own columns. Rather than writing a lossy representation into a cell that can never be parsed back correctly, `CSVSerializer#serialize` returns a `Typed::SerializeError` naming the offending field(s). Prefer this serializer for schemas with scalar fields only, or use an `inline_serializer` (see [Inline Serializers](#inline-serializers)) to flatten a nested field into a scalar before serializing it to CSV.
153
176
 
154
177
  #### YMLSerializer
155
178
 
@@ -183,14 +206,15 @@ result = message_pack_serializer.serialize(max)
183
206
  result.payload # == MessagePack.pack({"name" => "Max", "age" => 29})
184
207
  ```
185
208
 
186
- Unlike `CSVSerializer`, MessagePack natively supports nested maps and arrays, so nested `T::Struct`s, `Hash`es, and `Array`s round-trip without any `inline_serializer` needed.
209
+ Unlike `CSVSerializer`, MessagePack natively supports nested maps and arrays, so nested structs, `Hash`es, and `Array`s round-trip without any `inline_serializer` needed.
187
210
 
188
211
  #### ActiveRecordSerializer
189
212
 
190
- Requires the `activerecord` gem to be available. This is only checked when going through `.serializer(:activerecord)`, which raises an `ArgumentError` if the gem isn't loaded; calling `Typed::ActiveRecordSerializer.new` directly without ActiveRecord loaded instead raises a `NameError`, since the class itself references the `ActiveRecord` constant. The `Typed::ActiveRecordSerializer` converts between `ActiveRecord::Base` model instances and `T::Struct`s, matching fields by attribute name and mapping associated records through the model's declared associations. It requires a `model_class` option, in addition to `schema`, so it knows which `ActiveRecord::Base` subclass to deserialize from and serialize to:
213
+ Requires the `activerecord` gem in your application's Gemfile. The gem is loaded lazily when a serializer is constructed; both `.serializer(:activerecord)` and direct construction raise a clear `ArgumentError` if it is unavailable. Loading or reflecting on the serializer class does not require ActiveRecord to be installed. The `Typed::ActiveRecordSerializer` converts between `ActiveRecord::Base` model instances and either supported struct class, matching fields by attribute name and mapping associated records through the model's declared associations. It requires a `model_class` option, in addition to `schema`, so it knows which `ActiveRecord::Base` subclass to deserialize from and serialize to:
191
214
 
192
215
  ```ruby
193
216
  ar_serializer = Typed::ActiveRecordSerializer.new(schema: Person.schema, model_class: PersonModel)
217
+ # Sorbet infers ActiveRecordSerializer[PersonModel, Person], retaining both result types.
194
218
 
195
219
  # Deserialize from target format
196
220
  result = ar_serializer.deserialize(PersonModel.new(name: "Max", age: 29))
@@ -202,7 +226,7 @@ result.payload # == PersonModel instance with name: "Max", age: 29
202
226
 
203
227
  When serializing, only fields that map to a column (or to an association's declared foreign key) on `model_class` are assigned; the rest are ignored.
204
228
 
205
- Nested `belongs_to` associations are deserialized recursively to arbitrary depth, so a `T::Struct` field whose type is itself a `T::Struct` backed by a `belongs_to` association will have its own nested associations resolved too.
229
+ Nested `belongs_to` associations are deserialized recursively to arbitrary depth, including immutable struct fields backed by a `belongs_to` association.
206
230
 
207
231
  Deserializing reads through the model's association readers, so it is subject to the same N+1 query behavior as any other Rails association access. Preloading with `includes` works transparently and is the recommended way to avoid a query per record per association when deserializing a collection:
208
232
 
@@ -213,6 +237,48 @@ PersonModel.includes(:location).find_each do |person_model|
213
237
  end
214
238
  ```
215
239
 
240
+ ### Differing Serialized Keys
241
+
242
+ Sorbet's `name:` prop option is honored, so a struct can keep snake_case props while reading and writing the key an external payload actually uses:
243
+
244
+ ```ruby
245
+ class WebhookPayload < T::Struct
246
+ const :event_id, String, name: "eventId"
247
+ const :email_address, String, name: "emailAddress"
248
+ end
249
+
250
+ WebhookPayload.deserialize_from(:hash, {eventId: "evt-1", emailAddress: "user@example.com"})
251
+ # <Typed::Success value=#<WebhookPayload event_id="evt-1", email_address="user@example.com">>
252
+
253
+ WebhookPayload.new(event_id: "evt-1", email_address: "user@example.com").serialize_to(:hash)
254
+ # {eventId: "evt-1", emailAddress: "user@example.com"}
255
+ ```
256
+
257
+ `Typed::Field` exposes this as `serialized_name`, which defaults to `name` when the prop declares no `name:` option. Hand-built schemas can set it directly: `Typed::Field.new(name: :event_id, type: String, serialized_name: :eventId)`.
258
+
259
+ ### JSON Schema
260
+
261
+ A schema can describe the hash it deserializes from as a JSON Schema (draft 2020-12), e.g. to hand it to an API client or an LLM tool definition:
262
+
263
+ ```ruby
264
+ class Job < T::Struct
265
+ const :title, String, extra: {description: "Job title"}
266
+ const :start_date, T.nilable(Date)
267
+ end
268
+
269
+ Job.schema.to_json_schema
270
+ # => {
271
+ # "type" => "object",
272
+ # "properties" => {
273
+ # "title" => {"type" => "string", "description" => "Job title"},
274
+ # "start_date" => {"type" => ["string", "null"], "format" => "date"}
275
+ # },
276
+ # "required" => ["title"]
277
+ # }
278
+ ```
279
+
280
+ Properties are keyed by the serialized name, values are in serialized form (a `T::Enum` as the `enum` of its serialized values, a `Date` as a string), and a field is required when it is neither nilable nor defaulted. Deserialization ignores unknown keys, so objects are open by default; `to_json_schema(additional_properties: false)` closes every struct object, e.g. to catch typos in a config file or for LLM structured-output APIs that require it. Nested `T::Struct` and `T::ImmutableStruct` values are described through their own `schema`; pass `struct_schema:` to `Typed::JSONSchema.generate` to resolve them differently. A type without a JSON form raises `Typed::JSONSchema::UnsupportedTypeError`.
281
+
216
282
  ### Customization
217
283
 
218
284
  From the get-go, Sorbet Schema is designed to be extensible to model more complex data validation requirements and many serialization formats. We try out best to include built-in, battle-tested coercers and serializers from real world use cases and would love to see/upstream any customizations that the community have found useful!
@@ -230,13 +296,13 @@ class DateCoercer < Coercer
230
296
  Target = type_member { {fixed: Date} }
231
297
 
232
298
  sig { override.params(type: T::Types::Base).returns(T::Boolean) }
233
- def used_for_type?(type)
299
+ def self.used_for_type?(type)
234
300
  T::Utils.coerce(type) == T::Utils.coerce(Date)
235
301
  end
236
302
 
237
303
  sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
238
304
  def coerce(type:, value:)
239
- return Failure.new(CoercionError.new("Type must be a Date.")) unless used_for_type?(type)
305
+ return Failure.new(CoercionError.new("Type must be a Date.")) unless self.class.used_for_type?(type)
240
306
 
241
307
  return Success.new(value) if value.is_a?(Date)
242
308
 
@@ -270,11 +336,15 @@ The serializer can be used when creating a `Schema` and defining its `fields`, o
270
336
  ```ruby
271
337
  my_date_serializer = ->(date) { date.strftime("%Y/%m") }
272
338
 
273
- # use directly on a Schema
274
- Typed::Schema.new(
339
+ # use directly on a Schema - `Typed::Schema` is generic over its target struct,
340
+ # so prefer `from_struct`, which infers the type argument, over `Schema.new`
341
+ Typed::Schema.from_struct(SchemaWithDateField).add_serializer(:date, my_date_serializer)
342
+
343
+ # or, if you need custom `fields` up front, specify the type argument explicitly
344
+ Typed::Schema[SchemaWithDateField].new(
275
345
  target: SchemaWithDateField,
276
346
  fields: [
277
- Typed::Field.new(name: :date, type: Date, serializer: my_date_serializer)
347
+ Typed::Field.new(name: :date, type: Date, inline_serializer: my_date_serializer)
278
348
  ]
279
349
  )
280
350
 
@@ -292,10 +362,12 @@ require "json"
292
362
  class JSONSerializer < Serializer
293
363
  Input = type_member { {fixed: String} }
294
364
  Output = type_member { {fixed: String} }
365
+ StructT = type_member { {upper: T::InexactStruct} }
295
366
 
296
- sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }
367
+ sig { override.params(source: Input).returns(Result[StructT, DeserializeError]) }
297
368
  def deserialize(source)
298
369
  parsed_json = JSON.parse(source)
370
+ return Failure.new(ParseError.new(format: :json)) unless parsed_json.is_a?(Hash)
299
371
 
300
372
  creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
301
373
  hsh[field.name] = parsed_json[field.name.to_s]
@@ -306,7 +378,7 @@ class JSONSerializer < Serializer
306
378
  Failure.new(ParseError.new(format: :json))
307
379
  end
308
380
 
309
- sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }
381
+ sig { override.params(struct: StructT).returns(Result[Output, SerializeError]) }
310
382
  def serialize(struct)
311
383
  return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
312
384
 
@@ -315,7 +387,7 @@ class JSONSerializer < Serializer
315
387
  end
316
388
  ```
317
389
 
318
- Since `Serializer` is a generic class, we need to define our `Input` and `Output` types. For JSON, deserialization and serialization both use JSON strings, so these are both strings.
390
+ Since `Serializer` is a generic class, we need to define our `Input`, `Output`, and `StructT` types. For JSON, deserialization and serialization both use JSON strings, so `Input`/`Output` are both `String`. `StructT` is the struct type the serializer round-trips to/from - it must always be re-declared as `type_member { {upper: T::InexactStruct} }` in the subclass (Sorbet requires each generic type member to be re-declared down the inheritance chain). Schemas themselves continue to accept only `T::Struct` and `T::ImmutableStruct` descendants, while using `StructT` in `deserialize`/`serialize` preserves the concrete type at every deserialize entry point.
319
391
 
320
392
  Next, the `deserialize` and `serialize` methods must be implemented. Notice that both of these return `Result`s.
321
393
 
data/Rakefile CHANGED
@@ -4,6 +4,7 @@ require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
6
  Minitest::TestTask.create do |t|
7
+ t.framework = %(require "test_helper")
7
8
  t.test_globs = ["test/**/*_test.rb"]
8
9
  end
9
10
 
@@ -9,7 +9,7 @@ class SerializeValue
9
9
  HashTransformer.serialize_values(value)
10
10
  elsif value.is_a?(Array)
11
11
  value.map { |item| serialize(item) }
12
- elsif value.is_a?(T::Struct)
12
+ elsif value.is_a?(T::Struct) || value.is_a?(T::ImmutableStruct)
13
13
  value.serialize_to(:hash, options: {should_serialize_values: true}).payload_or(value)
14
14
  elsif value.respond_to?(:serialize)
15
15
  value.serialize
@@ -1,40 +1,59 @@
1
1
  # typed: true
2
2
 
3
3
  module T
4
- class Struct
5
- class << self
6
- def schema
7
- Typed::Schema.from_struct(self)
8
- end
9
-
10
- def serializer(type, options: {})
11
- case type
12
- when :hash
13
- Typed::HashSerializer.new(**T.unsafe({schema:, **options}))
14
- when :json
15
- Typed::JSONSerializer.new(schema:)
16
- when :csv
17
- Typed::CSVSerializer.new(schema:)
18
- when :yml
19
- Typed::YMLSerializer.new(schema:)
20
- when :msgpack
21
- Typed::MessagePackSerializer.new(schema:)
22
- when :activerecord
23
- raise ArgumentError, "activerecord gem is required for ActiveRecord serialization" unless defined?(ActiveRecord)
4
+ module StructClassMethods
5
+ def schema
6
+ T.bind(self, Typed::StructClass)
7
+ Typed::Schema.from_struct(self)
8
+ end
24
9
 
25
- Typed::ActiveRecordSerializer.new(**T.unsafe({schema:, **options}))
26
- else
27
- raise ArgumentError, "unknown serializer for #{type}"
10
+ def serializer(type, options: {})
11
+ T.bind(self, Typed::StructClass)
12
+ # The RBI shim promises a serializer parameterized by the attached class,
13
+ # while this case has different serializer subclasses on each branch.
14
+ T.unsafe(case type
15
+ when :hash
16
+ Typed::HashSerializer.new(**T.unsafe({schema:, **options}))
17
+ when :json
18
+ Typed::JSONSerializer.new(schema:)
19
+ when :csv
20
+ Typed::CSVSerializer.new(schema:)
21
+ when :yml
22
+ Typed::YMLSerializer.new(schema:)
23
+ when :msgpack
24
+ Typed::MessagePackSerializer.new(schema:)
25
+ when :activerecord
26
+ begin
27
+ require "active_record" unless defined?(ActiveRecord::Base)
28
+ rescue LoadError
29
+ raise ArgumentError, "activerecord gem is required for ActiveRecord serialization - add it to your Gemfile"
28
30
  end
29
- end
30
31
 
31
- def deserialize_from(serializer_type, source, options: {})
32
- T.unsafe(serializer(serializer_type, options:).deserialize(source))
33
- end
32
+ Typed::ActiveRecordSerializer.new(**T.unsafe({schema:, **options}))
33
+ else
34
+ raise ArgumentError, "unknown serializer for #{type}"
35
+ end)
34
36
  end
35
37
 
38
+ def deserialize_from(serializer_type, source, options: {})
39
+ T.unsafe(serializer(serializer_type, options:).deserialize(source))
40
+ end
41
+ end
42
+
43
+ module StructInstanceMethods
36
44
  def serialize_to(serializer_type, options: {})
37
- self.class.serializer(serializer_type, options:).serialize(self)
45
+ T.bind(self, Typed::StructValue)
46
+ T.unsafe(self.class.serializer(serializer_type, options:)).serialize(self)
38
47
  end
39
48
  end
49
+
50
+ class Struct
51
+ extend StructClassMethods
52
+ include StructInstanceMethods
53
+ end
54
+
55
+ class ImmutableStruct
56
+ extend StructClassMethods
57
+ include StructInstanceMethods
58
+ end
40
59
  end
@@ -1,5 +1,5 @@
1
1
  # typed: strict
2
2
 
3
3
  module SorbetSchema
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/sorbet-schema.rb CHANGED
@@ -12,6 +12,7 @@ loader.push_dir(__dir__.to_s)
12
12
  loader.ignore(__FILE__)
13
13
  loader.ignore("#{__dir__}/sorbet-schema/**/*.rb")
14
14
  loader.inflector.inflect(
15
+ "json_schema" => "JSONSchema",
15
16
  "json_serializer" => "JSONSerializer",
16
17
  "csv_serializer" => "CSVSerializer",
17
18
  "yml_serializer" => "YMLSerializer"
@@ -33,4 +34,6 @@ require_relative "sorbet-schema/t/struct"
33
34
  # Sorbet-aware namespace to super-charge your projects
34
35
  module Typed
35
36
  Value = T.type_alias { T.untyped }
37
+ StructValue = T.type_alias { T.any(T::Struct, T::ImmutableStruct) }
38
+ StructClass = T.type_alias { T.any(T.class_of(T::Struct), T.class_of(T::ImmutableStruct)) }
36
39
  end