Giter Site home page Giter Site logo

chanzuckerberg / sorbet-coerce Goto Github PK

View Code? Open in Web Editor NEW
29.0 11.0 12.0 306 KB

A type coercion lib works with Sorbet's static type checker and type definitions

Home Page: https://rubygems.org/gems/sorbet-coerce

License: MIT License

Ruby 99.93% Shell 0.07%
sorbet type-coercion type cast coerce coercion

sorbet-coerce's Introduction

sorbet-coerce

Gem Version GitHub Action: CI codecov

A type coercion lib works with Sorbet's static type checker and type definitions; raises an error if the coercion fails.

It provides a simple and generic way of coercing types in a sorbet-typed project. It is particularly useful when we're dealing with external API responses and controller parameters.

Installation

  1. Follow the steps here to set up the latest version of Sorbet and run srb tc.
  2. Add sorbet-coerce to your Gemfile and install them with Bundler.
# -- Gemfile --

gem 'sorbet-coerce'
❯ bundle install

Usage

TypeCoerce takes a valid sorbet type and coerce the input value into that type. It'll return a statically-typed object or throws errors when the coercion process cannot be handled as expected (more details in the Errors section).

converted = TypeCoerce[<Type>].new.from(<value>)

T.reveal_type(converted) # <Type>

Supported Types

  • Simple Types
  • Custom Types: If the values can be coerced by .new
  • T.untyped (an escape hatch to ignore & return the given value)
  • T::Boolean
  • T::Enum
  • T.nilable(<supported type>)
  • T::Array[<supported type>]
  • T::Hash[<supported type>, <supported type>]
  • T::Set[<supported type>]
  • T.any(<supported type>, ...)
  • Subclasses of T::Struct

We don't support

  • Experimental features (tuples and shapes)
  • passing in variables as types (ex TypeCoerce[var]) - please use at your own risk!

Examples

  • Simple Types
TypeCoerce[T::Boolean].new.from('false')
# => false

TypeCoerce[T::Boolean].new.from('true')
# => true

TypeCoerce[Date].new.from('2019-08-05')
# => #<Date: 2019-08-05 ((2458701j,0s,0n),+0s,2299161j)>

TypeCoerce[DateTime].new.from('2019-08-05')
# => #<DateTime: 2019-08-05T00:00:00+00:00 ((2458701j,0s,0n),+0s,2299161j)>

TypeCoerce[Float].new.from('1')
# => 1.0

TypeCoerce[Integer].new.from('1')
# => 1

TypeCoerce[String].new.from(1)
# => "1"

TypeCoerce[Symbol].new.from('a')
# => :a

TypeCoerce[Time].new.from('2019-08-05')
# => 2019-08-05 00:00:00 -0700
  • T.nilable
TypeCoerce[T.nilable(Integer)].new.from('')
# => nil
TypeCoerce[T.nilable(Integer)].new.from(nil)
# => nil
TypeCoerce[T.nilable(Integer)].new.from('')
# => nil

The behaviour for converting '' for the T.nilable(String) type depends on an option flag called coerce_empty_to_nil (new in v0.6.0):

# default behaviour
TypeCoerce[T.nilable(String)].new.from('')
# => ""

# using the coerce_empty_to_nil flag
TypeCoerce[T.nilable(String)].new.from('', coerce_empty_to_nil: true)
# => nil
  • T::Array
TypeCoerce[T::Array[Integer]].new.from([1.0, '2.0'])
# => [1, 2]
  • T::Struct
class Params < T::Struct
  const :id, Integer
  const :role, String, default: 'wizard'
end

TypeCoerce[Params].new.from({id: '1'})
# => <Params id=1, role="wizard">

More examples: nested params

Errors

We will get CoercionError, ShapeError, or TypeError when the coercion doesn't work successfully.

TypeCoerce::CoercionError (configurable)

It raises a coercion error when it fails to convert a value into the specified type (i.e. 'bad string args' to Integer). This can be configured globally or at each call-site. When configured to true, it will fill the result with nil instead of raising the errors.

TypeCoerce::Configuration.raise_coercion_error = false # default to true

We can use an inline flag to overwrite the global configuration:

TypeCoerce[T.nilable(Integer)].new.from('abc', raise_coercion_error: false)
# => nil

TypeCoerce::ShapeError (NOT configurable)

It raises a shape error when the shape of the input does not match the shape of input type (i.e. '1' to T::Array[Integer] or to T::Struct). This cannot be configured and always raise an error.

TypeError (configurable)

It raises a type error when the coerced input does not match the input type. This error is raised by Sorbet and can be configured through T::Configuration.

Soft Errors vs. Hard Errors

In an environment where type errors and coercion errors are configured to be silent (referred to as soft errors), when the coercion fails, TypeCoerce will fill the result with nil instead of actually raising the errors (referred to hard errors).

With hard errors,

class Params < T::Struct
  const :a, Integer
end

TypeCoerce[Integer].new.from(nil)
# => TypeError Exception: T.let: Expected type Integer, got type NilClass

TypeCoerce[Integer].new.from('abc')
# => TypeCoerce::CoercionError Exception: Could not coerce value ("abc") of type (String) to desired type (Integer)

TypeCoerce[T.nilable(Integer)].new.from('abc', raise_coercion_error: false)
# => nil

TypeCoerce[Params].new.from({a: 'abc'}, raise_coercion_error: false)
# => TypeError Exception: Parameter 'a': Can't set Params.a to nil (instance of NilClass) - need a Integer

With soft errors,

TypeCoerce[Integer].new.from('abc', raise_coercion_error: false)
# => nil

TypeCoerce[Params].new.from({a: 'abc'}, raise_coercion_error: false) # require sorbet version ~> 0.4.4948
# => <Params a=nil>

TypeCoerce[Params].new.from({a: 'abc'}, raise_coercion_error: true)
# TypeCoerce::CoercionError Exception: Could not coerce value ("abc") of type (String) to desired type (Integer)

null, '', and undefined

Sorbet-coerce is designed in the context of web development. When coercing into a T::Struct, the values that need to be coerced are often JSON-like. Suppose we send a JavaScript object

json_js = {"a": "1", "null_field": null, "blank_field": "", "missing_key": undefined} // javascript

to the server side and get a JSON hash

json_rb = {"a" => "1", "null_field" => nil, "blank_field" => ""} # ruby, note `missing_key` is removed from the hash

We expect the object to have shape

class Params < T::Struct
  const :a, Integer
  const :null_field, T.nilable(Integer)
  const :blank_field, T.nilable(Integer)
  const :missing_key, T::Array[Integer], default: []
end

Then we coerce the object json_rb into an instance of Params.

params = TypeCoerce[Params].new.from(json_rb)
# => <Params a=1, blank_field=nil, missing_key=[], null_field=nil>
  • When json_js["null_field"] is null, params.null_field is nil
  • When json_js["blank_field"] is "", params.blank_field is nil
  • When json_js["missing_key"] is undefined, params.missing_key will use the default value []

Contributing

Contributions and ideas are welcome! Please see our contributing guide and don't hesitate to open an issue or send a pull request to improve the functionality of this gem.

This project adheres to the Contributor Covenant code of conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to [email protected].

License

This project is licensed under MIT.

sorbet-coerce's People

Contributors

datbth avatar diego-silva avatar donaldong avatar gasparila avatar hlascelles avatar katyho avatar leifg avatar mattxwang avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

sorbet-coerce's Issues

Supporting Enum

Before I roll my sleeves up and try a PR is there a reason Enums are not supported, or is there something tricky about them I won't expect?

I can't remove the bug label. Can we get a new issue template that isn't bug or vulnerability?

Union Type Confusion

Describe the bug:
I would expect the following to be a "coercable" union type:

class Demo
  extend T::Sig

  class Test < T::Struct
    const :key, String
    const :value, String
  end

  sig { void }
  def self.help
    TypeCoerce[T.any(String, Test)].new.from(
      {
        "key" => "Hello",
        "value" => "World",
      },
    )
  end
end

Actual result:

TypeError: T.let: Expected type T.any(Demo::Test, String), got type Hash with value {"key"=>"Hello", "value"=>"World" }

This does seem to work with the other half of the union type (i.e. for a String) where we're able to return a String directly:

sig { returns(String) }
def self.help
  TypeCoerce[T.any(String, Test)].new.from(
    "Hi",
  )
end

Expected behavior:

Naively, I would expect the above to return the Test struct with the properly initialized values. Is this a known limitation? And would this be a possible feature to support?

Versions:

  • Ruby: 2.7.2p137
  • Sorbet: 0.5.9449
  • Sorbet-Coerce: 0.6.0

[Question] about sorbet dependency

I love this gem. Very useful.

I'm wondering whether it's better to move sorbet dependency to development only?

The static analysis is only useful in development mode. In production, the sorbet-runtime is the must have gem IMO.

I'm not sure how sorbet-coerce works, but IMO in runtime the sorbet-coerce would only need the sorbet-runtime dependency

Support T::Struct prop names

Describe the bug:

Doesn't currently seem to support T::Struct prop names.

Steps to reproduce:

[1] pry(main)> class X < T::Struct; const :foo, Integer, name: 'bar'; end
=> T::Private::Types::Void::VOID
[2] pry(main)> X.from_hash({ 'bar' => 123 })
=> <X foo=123>
[3] pry(main)> X.from_hash({ 'foo' => 123 })
RuntimeError: Error in X#__t_props_generated_deserialize: Tried to deserialize a required prop from a nil value. [SNIP]
[4] pry(main)> TypeCoerce[X].new.from({ 'bar' => 123 })
ArgumentError: Missing required prop `foo` for class `X` [SNIP]
[5] pry(main)> TypeCoerce[X].new.from({ 'foo' => 123 })
=> <X foo=123>
[6] pry(main)> 

Expected behavior:

TypeCoerce[Struct].new.from should probably behave similar to Struct.from_hash.

Versions:

  • Sorbet-Coerce: 0.7.0

BigDecimal

I had a situation where I wanted to coerce into a BigDecimal/Numeric (I was actually using a T::Struct). The reason for a igDecimal was that the API sometimes receives strings, and sometimes integers (JSON). I want to convert both to a numeric without data loss.

TypeCoerce[T.nilable(Integer)].new.from('123') => 123
TypeCoerce[T.nilable(Integer)].new.from(123) => 123

TypeCoerce[T.nilable(Float)].new.from('123.2') => 123.2
TypeCoerce[T.nilable(Float)].new.from(123.2) => 123.2

TypeCoerce[T.nilable(Float)].new.from('2823111111111111111111111111111111111111111111111111111111111111111111111111111111111111.12').round ==> 2823111111111111211310091539389163649541789162060078943821790215899541495486114917515264

Given I am using these values for pricing information I can not loose precision (although would be happy with some form of Numeric class with arbitrary precision.

Currently I am parsing the input as a string and then converting to a BigDecimal before usage, however I would rather convert to a BigDecimal during the conversion to a T::Struct.

Allow configuration for erroring on unknown keys

Potential new feature

It may be useful to allow sorbet-coerce to blow up on unknown keys

Steps to reproduce:

class Foo < T::Struct
  const :bar, String
end

TypeCoerce[Foo].new.from({ bar: "Hello", foo: "New key!" })
# => <Foo bar="Hello">

# The above succeeds, but I'd like it to (configurably) blow up as `foo` is not known.

Versions:

  • Ruby: 2.7
  • Sorbet: 0.5
  • Sorbet-Coerce: 0.5.0

I re-re-read the docs but don't think it is currently possible.
Is it, and if not would you like it to be, when configured?

mismatched typing for `TypeCoerce[<T>].new.from` (with Tapioca)

Deep Dive Summary

Describe the bug:

Right at the end of my internship, we successfully moved infra-tasks to fully use the new Tapioca for RBI gen. Awesome! However, I did run into one hiccup (that I can no longer see :/ ) in using Tapioca to generate RBIs - in particular, that the RBI it generated is incompatible with sorbet-coerce!

After doing some thinking, I think that Tapioca is right, and our provided/canonical RBI is wrong (or at the very least, does not fully describe sorbet-coerce's behaviour). Here's the (relevant portion of) the RBI that's in bundled_rbi / in sorbet-typed / committed in traject, infra-tasks, etc.:

module TypeCoerce
  extend T::Sig
  extend T::Generic

  Elem = type_member

  sig { params(args: T.untyped, raise_coercion_error: T.nilable(T::Boolean), coerce_empty_to_nil: T::Boolean).returns(Elem) }
  def from(args, raise_coercion_error: nil, coerce_empty_to_nil: false); end
end

In other words, we are marking TypeCoerce as a generic, and so something like TypeCoerce[<T>].new.from(...) should behave in this order:

  • TypeCoerce[<T>] is the generic type with the type_member
  • TypeCoerce[<T>].new creates an instance
  • TypeCoerce[<T>].new.from(...) uses the sig we just defined, and so it must be of type <T>

Compare this with what the code actually does. Looking at sorbet-coerce.rb:

module TypeCoerce
  def self.[](type)
    TypeCoerce::Converter.new(type)
  end
end

I don't have the context on why this was implemented this way (my guess - Sorbet's generics API wasn't finalized yet).

However, when Tapioca does its runtime introspection, it says:

  • TypeCoerce[<T>] is not a generic type: it's overloaded (?) and returns an instance of TypeCoerce::Converter
  • uh oh, TypeCoerce::Converter is not typed (and has no sigs). Let's call it T.untyped then!
  • TypeCoerce[<T>].new is calling .new on T.untyped. no bueno (or, T.untyped)
  • TypeCoerce[<T>].new.from(...) is calling .from on T.untyped. double no bueno (or, T.untyped)!
  • we never actually hit our manually-written sig that we wrote! :(

This creates a large T.untyped chain, and means that the result of the coercion is T.untyped - surely not what we want.

In my opinion, this is incorrect typing: while it's true at a very black-boxed level, any amount of runtime introspection causes tension with sorbet.

I'm not 100% sure this is a correct diagnosis of the behaviour - and it's harder for me to confirm now that I can't see the codebase. But, I'm pretty confident that there's something wrong with our bundled types, and hopefully this is at least a good start!

(and, note that this bug doesn't affect the runtime (like the other sorbet-coerce bug I ran into) - since at runtime, our expression still typechecks)

potential solutions

I think there are two ways forward:

  1. Fix how sorbet-coerce works with tapioca at an "interface" level, either by overriding Tapioca's RBI or manually introducing a shim. This is what I did in infra-tasks, but it feels unsustainable/hacky.
    • an extension of this could be to type Converter!
  2. Actually fix this incorrect typing, by making Converter use sorbet generics under-the-hood (instead of passing the type as a field)
    • this is what I was exploring in #76 but never got around to!
    • not sure if this is possible

things included in the issue template

Steps to reproduce:

Create a new project that consumes sorbet-coerce (and uses it somewhere). Then,

  • install tapioca
  • run bundle exec tapioca init (implicitly - runs tapioca gem)
  • run bundle exec srb tc - the static typecheck fails

Note that, at runtime, the code works! Though, it seems like this is somewhat undefined behaviour (re: Jake Zimmerman's previous comment in the Sorbet slack about Sorbet not supporting programming at the type-level).

Expected behavior:

Static typecheck should pass.

Actual behaviour:

Static typecheck fails.

Versions:

  • Ruby: 3.1
  • Sorbet: ~ 0.5.10400 (not super important)
  • Sorbet-Coerce: 0.7.0
  • Tapioca: 0.10.0

[Question] Accept PR for config flag on how to deal with empty strings

I would love a feature where an empty string is coerced into a nil value.

So something like this:

TypeCoerce[T.nilable(String)].new.from('', coerce_empty_to_nil: true)
=> nil

This can be super useful when parsing request parameter (empty fields are passed in as an empty string)

Would you accept a PR like this?

Migrate to new Codecov uploader (stop using `codecov-ruby`)

Looking at the library README:

On February 1, 2022 this uploader will be completely deprecated and will no longer be able to upload coverage to Codecov.

It is past February 1, 2022! We should migrate to the new uploader. I'm not sure how this works permissions-wise, but definitely something I can look into doing.

Removing the codecov gem allows us to completely update all of our RBIs with bundle exec srb rbi gems and bundle exec srb rbi hidden-definitions to resolve all existing bundle exec srb tc problems.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.