soutaro / steep

Static type checker for Ruby
MIT License
1.38k stars 87 forks source link

`@as`(value casting in TypeScript) needed #296

Open Narazaka opened 3 years ago

Narazaka commented 3 years ago

I think Steep needs "value casting", which is equivalent to as in TypeScript.

@as is an annotation for a new "value typecast" rather than the currently existing "variable definition modification" (@type var foo: T).

    # @type var parts: {a: Integer, b: Integer}
    parts =
      # @as: {a: Integer, b: Integer}
      {}

The @as chain should also be possible.

    # @type var myvar2: MyType
    myvar2 =
      # @as: Integer
      # @as: untyped
      myvar

A concrete use case is to construct a hash that is sure to contain a particular key without any initial value.

In the following Foo#bar, we want to return a hash {a: Integer, b: Integer} of the result of the calculation for each default key. (This is a simplification of the original implementation of ActiveSupport::Duration.build)

class Foo
  KEYS = [:a, :b]

  def initialize()
    @other = {a: 1, b: 2}
  end

  def bar() # returns {a: Integer, b: Integer}
    parts = {}
    KEYS.each do |k|
      parts[k] = calc(@other[k])
    end
    parts
  end

  def calc(value)
    value * 2
  end
end

I think it's reasonable that the type should look like this

class Foo
  KEYS: [:a, :b]
  @other: {a: Integer, b: Integer}
  def initialize: () -> untyped
  def bar: () -> {a: Integer, b: Integer}
  def calc: (Integer) -> Integer
end

But this will of course throw an error, because parts will be Hash[untyped, untyped].

lib/foo.rb:7:2: MethodBodyTypeMismatch: method=bar, expected={ :a => ::Integer, :b => ::Integer }, actual=::Hash[untyped, untyped] (def bar())
  ::Hash[untyped, untyped] <: { :a => ::Integer, :b => ::Integer }
==> ::Hash[untyped, untyped] <: { :a => ::Integer, :b => ::Integer } does not hold

So add type annotations to parts.

  def bar()
    # @type var parts: {a: Integer, b: Integer}
    parts = {}

But now {} is Hash[untyped, untyped], so parts = {} throws an error.

lib/foo.rb:11:4: IncompatibleAssignment: lhs_type={ :a => ::Integer, :b => ::Integer }, rhs_type={  } (parts = {})
  {  } <: { :a => ::Integer, :b => ::Integer }
   nil <: ::Integer
==> nil <: ::Integer does not hold

This can be solved by putting an initial value like parts = {a: 0, b: 0}, but this is not a natural implementation.

It is not appropriate to change the implementation for types in such obvious cases, and it would be ideal to be able to type with parts = {}.

Gradual typing into dynamically typed languages is for humans, so I think the very annoying hassle of "having to tweak the implementation to get rid of type errors" defeats the purpose. (changing the implementation may require retesting!)

TypeScript provides a workaround for such a case by using as, which can cast to a value even if it is incorrect.

A similar implementation in TypeScript can be written as follows

class Foo {
  static KEYS = ["a", "b"]

  other: {a: number; b: number}

  constructer() {
    this.other = {a: 1, b: 2}
  }

  bar(): {a: number; b: number} {
    const parts = {}
    for (const k of Foo.KEYS) {
      parts[k] = this.calc(this.other[k])
    }
    return parts
  }

  calc(value: number) {
    return value * 2
  }
}

As in ruby, the error is that {} (roughly equivalent to Hash[untyped, untyped]) and {a: number; b: number} are mismatched.

lib/foo.ts:15:5 - error TS2739: Type '{}' is missing the following properties from type '{ a: number; b: number; }': a, b

15     return parts
       ~~~~~~~~~~~~

You can also specify the variable type of parts as a workaround.

  bar(): {a: number; b: number} {
    const parts: {a: number; b: number} = {}

However, as with Ruby, you will get an error that {} cannot be assigned to parts.

lib/foo.ts:11:11 - error TS2739: Type '{}' is missing the following properties from type '{ a: number; b: number; }': a, b

11     const parts: {a: number; b: number} = {}
             ~~~~~

However, TypeScript has as.

  bar(): {a: number; b: number} {
    const parts: {a: number; b: number} = {} as {a: number; b: number}

This will turn {} into {a: number; b: number} (which is different from what it actually is), and there will be no errors.

In addition, TypeScript type checking supports the as equivalent in the form of /** @type {T} */(val), even if type annotations are added as in JavaScript syntax.

// @ts-check

class Foo {
  /** @type {["a", "b"]} */
  static KEYS = ["a", "b"]

  constructer() {
    /** @type {{a: number; b: number}} */
    this.other = {a: 1, b: 2}
  }

  /**
   * @return {{a: number; b: number}}
   */
  bar() {
    /** @type {{a: number; b: number}} */
    const parts = /** @type {{a: number; b: number}} */({})
    for (const k of Foo.KEYS) {
      parts[k] = this.calc(this.other[k])
    }
    return parts
  }

  /**
   * @param {number} value 
   */
  calc(value) {
    return value * 2
  }
}

In Ruby, range comments are difficult to use, so it is difficult to define them in the same way, but I think it is possible to solve the problem by adding annotations to the value side as follows.

    # @type var parts: {a: Integer, b: Integer}
    parts =
      # @as: {a: Integer, b: Integer}
      {}

Note that as in TypeScript cannot be converted to completely incompatible types. However, it is often the case that a completely incompatible cast mystring as any as number (in JavaScript, /** @type {number} */(/** @type {any} */(mystring))) is required due to, for example, incomplete type definitions in libraries. If this is not possible, there will be no workaround for typing errors in the library.

So, if you have a similar specification for as, you also need a chain of as, such as myvar as any as number, which should be defined in two consecutive lines.

    # @type var myvar2: MyType
    myvar2 =
      # @as: Integer
      # @as: untyped
      myvar
soutaro commented 3 years ago

I was thinking of adding a cast syntax, and here is a draft of the proposal. Any thoughts? https://hackmd.io/oUkkX_jqS5W_5mvvpN--2A

Narazaka commented 3 years ago

I have one question.

When I mentioned above that the @as chain is required, I was assuming that type compatibility would be checked in this typecast as well.

For example, in TypeScript, {} as {a: number} can be cast without warning, but 1 as string will cause a warning unless you write 1 as unknown as string.

Is there a type compatibility check for such typecasting, is there one now, or will there be one in the future?

If there is a possibility of checking, I think we need a syntax for chaining @as. This is because there should be a workaround for modules released with the wrong type.

Narazaka commented 3 years ago

However, I think the current syntax can be extended to, for example, # @as untyped @as String. If such an extension is allowed in the implementation, I think it would be fine to release it with the current syntax definition as is, without any type checking planned at this stage.

Narazaka commented 3 years ago

The typecasting of values makes it possible to ignore probably almost all implementation type errors, even when strictly typed in rbs. I think this would be a real "incremental" type benefit. I'm looking forward to it.

soutaro commented 3 years ago

We will implement a type checking between before-after types with @as syntax.

The reason why we need chained-casts is that we often want casts between irrelevant types, right? And I believe introducing another unchecked casting syntax, @as! T or @as unchecked T, would solve the problem.

Narazaka commented 3 years ago

Yes, that's the use case I was thinking of. I think the cast without type checking you suggested would certainly solve the problem!

wagenet commented 2 years ago

This would be very helpful. Right now I have a method that has a return like my_string[0, 12] this will always return String since the starting index is 0. However, the rbs types see it as return String | nil. The only current workaround I see is to do my_string[0, 12] || "" even though the || will never get hit.