{"id":24306310,"url":"https://github.com/maxveldink/sorbet-schema","last_synced_at":"2025-06-26T20:03:46.823Z","repository":{"id":221596532,"uuid":"754616404","full_name":"maxveldink/sorbet-schema","owner":"maxveldink","description":"Extendable serialization and deserialization to various formats for Sorbet T::Structs","archived":false,"fork":false,"pushed_at":"2024-12-10T19:08:30.000Z","size":1611,"stargazers_count":15,"open_issues_count":10,"forks_count":3,"subscribers_count":3,"default_branch":"main","last_synced_at":"2025-04-08T04:42:02.277Z","etag":null,"topics":["ruby","ruby-gem","sorbet","sorbet-static"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/maxveldink.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null},"funding":{"ko_fi":"maxveldink","github":"maxveldink"}},"created_at":"2024-02-08T12:40:06.000Z","updated_at":"2025-04-01T12:15:47.000Z","dependencies_parsed_at":"2024-02-08T23:17:50.820Z","dependency_job_id":"11db4d82-6825-476a-9b42-7329d031d6ed","html_url":"https://github.com/maxveldink/sorbet-schema","commit_stats":null,"previous_names":["maxveldink/sorbet-schema"],"tags_count":20,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxveldink%2Fsorbet-schema","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxveldink%2Fsorbet-schema/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxveldink%2Fsorbet-schema/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxveldink%2Fsorbet-schema/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/maxveldink","download_url":"https://codeload.github.com/maxveldink/sorbet-schema/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxveldink%2Fsorbet-schema/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":258990545,"owners_count":22789105,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ruby","ruby-gem","sorbet","sorbet-static"],"created_at":"2025-01-17T02:48:08.235Z","updated_at":"2025-06-26T20:03:46.741Z","avatar_url":"https://github.com/maxveldink.png","language":"Ruby","funding_links":["https://ko-fi.com/maxveldink","https://github.com/sponsors/maxveldink"],"categories":["Ruby"],"sub_categories":[],"readme":"# Sorbet Schema\n\nExtendable serialization and deserialization to various formats for Sorbet `T::Struct`s.\n\n## Installation\n\nInstall the gem and add to the application's Gemfile by executing:\n\n    $ bundle add sorbet-schema\n\nIf bundler is not being used to manage dependencies, install the gem by executing:\n\n    $ gem install sorbet-schema\n\n## Usage\n\nSorbet 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`.\n\n### Getting Started\n\nWhile 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`.\n\n```ruby\nclass Person \u003c T::Struct\n  const :name, String\n  const :age, Integer\nend\n\nschema = Person.schema # =\u003e \u003cTyped::Schema\n#                              fields=[....]\n#                              target=Person\u003e\n```\n\nOnce you have a schema, you can use the built-in serializers (or a [custom one](#implementing-custom-serializers) that inherits from the [Typed::Serializer](https://github.com/maxveldink/sorbet-schema/blob/main/lib/typed/serializer.rb) abstract base class) to create new instances of the struct or convert an instance of the struct to the target format.\n\n```ruby\njson_serializer = Typed::JSONSerializer.new(schema: Person.schema)\n\n# Deserialize from target format\nresult = json_serializer.deserialize('{\"name\":\"Max\",\"age\":29}')\nmax = result.payload # == Person.new(name: \"Max\", age: 29)\n\nresult = json_serializer.serialize(max)\nresult.payload # == '{\"name\":\"Max\",\"age\":29}'\n```\n\nAlternatively, you can use the built-in helper methods added to `T::Struct`s to quickly use the built-in serializers.\n\n```ruby\nresult = Person.deserialize_from(:json, '{\"name\":\"Max\",\"age\":29}')\nmax = result.payload # == Person.new(name: \"Max\", age: 29)\n\nresult = max.serialize_to(:json)\nresult.payload # == '{\"name\":\"Max\",\"age\":29}'\n```\n\nNotice that both `deserialize` and `serialize` return `Typed::Result`s (from the [sorbet-result gem](https://github.com/maxveldink/sorbet-result)) that need to be checked for success or failure before being used. Check out that gem's README for more information on how to interact with `Result`s.\n\nOne benefit of using `Result`s is we can add much more details information about why a format is unsuccessfully deserialized or serialized, to provide call sites with more information for error handling, messaging and formatting.\n\n```ruby\n# Unparsable JSON\nresult = json_serializer.deserialize('{\"name\"\"Max\",\"age\":29}')\nresult.error # == Typed::ParseError: json could not be parsed. Check for typos.\n\n# Missing required field\nresult = json_serializer.deserialize('{\"age\": 29}')\nresult.error # == Typed::Validations::RequiredFieldError: name is required.\n\nresult = json_serializer.deserialize('{\"age\":\"29-0\"}')\nresult.error # == Typed::Validations::MultipleValidationError: Multiple validation errors found: name is required. | '29-0' cannot be coerced into Integer.\n```\n\nFinally, there are built-in coercers that do their best effort to convert common types from the source format to the required schema type.\n\n```ruby\n# Deserialize from target format, with integer coercion\nresult = json_serializer.deserialize('{\"name\":\"Max\",\"age\":\"29\"}')\nmax = result.payload # == Person.new(name: \"Max\", age: 29)\n```\n\n### Rails Example\n\nHere's an extended example of how Sorbet Schema can be combined with a normal Rails request to easily convert between formats.\n\n```ruby\ndef verify\n  Typed::HashSerializer\n    .new(schema: Address.schema) # Generate schema from the `Address` Struct\n    .deserialize(address_params.to_h) # Use Rails' strong parameters to deserialize into the struct\n    .and_then { |address| VerifyAddress.new.call(address: T.cast(address, Address)) } # Use sorbet-result's chaining\n    .and_then do |address|\n        return render json: Typed::JSONSerializer.new(schema: Address.schema).serialize(address).payload # return a JSON response from the Address struct instance\n    end\n    .on_error do |failure| # Use sorbet-result's error handling\n      case failure\n      when AddressNotFoundError\n        head :not_found\n      when GeoNotSupportedError\n        head :not_implemented\n      else\n        render json: failure, status: :bad_request # use `Typed::Failure`s built-in `to_json` behavior\n      end\n    end\nend\n```\n\n### Available Serializers\n\nThese are the currently available serializers. For more information about implementing a custom one (or contributing one back!), see [Custom Coercers](#custom-coercers).\n\n#### JSONSerializer\n\nSee [Getting Started](#getting-started) for more information on how to use the JSONSerializer.\n\n#### HashSerializer\n\nWhile 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.\n\nTo use it, simply instantiate and use it like the `JSONSerializer`:\n\n```ruby\nhash_serializer = Typed::HashSerializer.new(schema: Person.schema)\n\n# Deserialize from target format\nresult = hash_serializer.deserialize({\"name\" =\u003e \"Max\", age: 29})\nmax = result.payload # == Person.new(name: \"Max\", age: 29)\n```\n\nBy default, the `HashSerializer` will _not_ serialize values when converting to a Hash. For instance, if a field is an `T::Enum` type, when it is serialized to a `Hash` the value will be the `Enum` and not the `String` representation. The `should_serialize_values` option can be passed during initialization to serialize the values when converting to the `Hash`.\n\n### Customization\n\nFrom 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!\n\n#### Custom Coercers\n\nAt their simplest forms, coercers are any class that inherit from the [Typed::Coercion::Coercer](https://github.com/maxveldink/sorbet-schema/blob/main/lib/typed/coercion/coercer.rb) abstract base class. The list of default coercers that are applied can be found in the [CoercerRegistry](https://github.com/maxveldink/sorbet-schema/blob/main/lib/typed/coercion/coercer_registry.rb). Let's look at the [DateCoercer's implementation](https://github.com/maxveldink/sorbet-schema/blob/main/lib/typed/coercion/date_coercer.rb):\n\n```ruby\nrequire \"date\"\n\nclass DateCoercer \u003c Coercer\n  extend T::Generic\n\n  Target = type_member { {fixed: Date} }\n\n  sig { override.params(type: T::Types::Base).returns(T::Boolean) }\n  def used_for_type?(type)\n    T::Utils.coerce(type) == T::Utils.coerce(Date)\n  end\n\n  sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }\n  def coerce(type:, value:)\n    return Failure.new(CoercionError.new(\"Type must be a Date.\")) unless used_for_type?(type)\n\n    return Success.new(value) if value.is_a?(Date)\n\n    Success.new(Date.parse(value))\n  rescue Date::Error, TypeError\n    Failure.new(CoercionError.new(\"'#{value}' cannot be coerced into Date.\"))\n  end\nend\n```\n\nNotice that this utilizes sorbet generic, so the target type must be defined using `type_member`. For dates, this is the built-in std lib type `Date`.\n\nFrom there, implement the `used_for_type?` method which receives a type and returns `true` if the coercer can be used to coerce to that type or `false` if it should not be used. Notice that we use the `T::Types` module directly from Sorbet, which allows us to model the built-in Sorbet types, such as `T::Boolean` and `T::Array`. Typically, `T::Utils.coerce(TargetType)` is used to match the target type. For dates, this is a very simple type check for a `Date`.\n\nFinally, implement the `coerce` method. If a coercion is successful, return a `Success.new(coerced_value)`. If not, return a Failure with a coercion error `Failure.new(CoercionError.new(\"I can't coerce to the type\"))`. Take care to handle any exceptions that could arise from the attempted coercion. For dates, first it checks and make sure the type given matches the target type. This is a common check and is largely an edge case check for completeness. Next, if the value is already a Date we simply return a `Success` with it. Finally, we use the built-in `Date.parse` method to actually attempt a coercion. Since this can throw a `Date::Error` and a `TypeError`, rescue from those with a `Failure`.\n\nOnce a custom coercer is defined, the last step is to register it with Sorbet Schema during initialization. Typically, this is after `sorbet-schema` has been required or during the bootstrapping step of a framework, such as Rails' initializers. Call `register_coercer` like so:\n\n```ruby\nTyped::Coercion.register_coercer(MyCoercer) # make sure `MyCoercer` is loaded by this point\n```\n\n**Note** Custom coercers are prepended to the list of available coercers so that they are checked during deserialization before the built-in coercers. This allows consuming projects to override default behavior by creating a coercer that re-implements the `coerce` method for that type.\n\n#### Inline Serializers\n\nSometimes, there is custom behavior that needs to be added to how a field is serialized (represented as a `String`), such as when you need to use a different `strftime` format for `Date`s and `Time`s. This can be accomplished with an `InlineSerializer` (defined in [Typed::Field](https://github.com/maxveldink/sorbet-schema/blob/main/lib/typed/field.rb)), which is a `Proc` that takes the value and returns a different representation. At present, these are both very loose `T.untyped` types to allow for flexibility. Typically, a `String` is returned.\n\nThe serializer can be used when creating a `Schema` and defining its `fields`, or with the `add_serializer` helper on `Schema`s.\n\n```ruby\nmy_date_serializer = -\u003e(date) { date.strftime(\"%Y/%m\") }\n\n# use directly on a Schema\nTyped::Schema.new(\n  target: SchemaWithDateField,\n  fields: [\n    Typed::Field.new(name: :date, type: Date, serializer: my_date_serializer)\n  ]\n)\n\n# use `add_serializer` helper\nSchemaWithDateField.schema.add_serializer(:date, my_date_serializer)\n```\n\n#### Implementing Custom Serializers\n\nWhile Sorbet Schema ships with popular serializers, you can define your own by inheriting from [Typed::Serializer](https://github.com/maxveldink/sorbet-schema/blob/main/lib/typed/serializer.rb). Let's look at the `JSONSerializer`:\n\n```ruby\nrequire \"json\"\n\nclass JSONSerializer \u003c Serializer\n  Input = type_member { {fixed: String} }\n  Output = type_member { {fixed: String} }\n\n  sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }\n  def deserialize(source)\n    parsed_json = JSON.parse(source)\n\n    creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|\n      hsh[field.name] = parsed_json[field.name.to_s]\n    end\n\n    deserialize_from_creation_params(creation_params)\n  rescue JSON::ParserError\n    Failure.new(ParseError.new(format: :json))\n  end\n\n  sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }\n  def serialize(struct)\n    return Failure.new(SerializeError.new(\"'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.\")) if struct.class != schema.target\n\n    Success.new(JSON.generate(serialize_from_struct(struct: struct, should_serialize_values: true)))\n  end\nend\n```\n\nSince `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.\n\nNext, the `deserialize` and `serialize` methods must be implemented. Notice that both of these return `Result`s.\n\nFor deserialization, the JSON is parsed (and a parse error is handled). Then we build up a creation params hash from the parsed json to pass to the `deserialize_from_creation_params` helper, defined on `Serializer`.\n\nFor serialization, the passed struct is checked to make sure it matches the `Schema`. Then it uses the `serialize_from_struct` helper and passes the resulting `Hash` to generate JSON.\n\n## Inspirations\n\nThis project is heavily inspired by [serde](https://serde.rs/) from the Rust community and the [dry-rb](https://dry-rb.org/) family of gems.\n\n## Development\n\nAfter checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run Standard and the tests. `bin/console` for an interactive prompt that aids with experimentation.\n\nTo install this gem onto a local machine, run `bundle exec rake install`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/maxveldink/sorbet-schema. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/maxveldink/sorbet-schema/blob/master/CODE_OF_CONDUCT.md).\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n\n## Code of Conduct\n\nEveryone interacting in this project's codebase, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/maxveldink/sorbet-schema/blob/master/CODE_OF_CONDUCT.md).\n\n## Sponsorships\n\nI love creating in the open. If you find this or any other [maxveld.ink](https://maxveld.ink) content useful, please consider sponsoring me on [GitHub](https://github.com/sponsors/maxveldink).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmaxveldink%2Fsorbet-schema","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmaxveldink%2Fsorbet-schema","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmaxveldink%2Fsorbet-schema/lists"}