Qubyte Codes

Tip: Type narrowing arrays for sorbet in ruby

Published

When working with type systems like TypeScript or Sorbet, type narrowing patterns are a way to handle different types a variable may contain in different branches. For example:

sig do
  params(
    n: T.nilable(Integer)
  ).returns(
    T.nilable(Integer)
  )
end
def double(n)
  if n
    # n can't be nil in this branch, so sorbet
    # knows the type is narrowed to Integer.
    n * 2
  else
    # This branch could be implied, but is
    # here for clarity.
    nil
  end
end

Type narrowing applies to more than just T.nilable(X) to X. You can use it to refine a type to a subset of some types allowed by a union, or from a value of some class to a value of a child class.

This works well on singular values, but when filtering an array sorbet asserts that the resultant array has the same type. My guess is that this is to ensure consistency between select and select! (the latter modifies in place).

sig do
  params(
    array: T::Array[T.nilable(Integer)]
  ).returns(
    T::Array[Integer]
  )
end
def filter_and_double_array(array)
  # Sorbet thinks that integers is
  # T::Array[T.nilable(Integer)] :(
  integers = array.select { |n| n }

  # So it thinks the next line is broken,
  # although we know it's safe.
  integers.map { |n| n * 2 }
end

The brute force option here is to T.cast(x, Integer) in the map, but escape hatches like T.cast and T.must are a last resort. When the type system is telling us the type we're safer. There's also a runtime overhead to using T.cast or T.must.

The solution is to use filter_map. It can be used to filter out the type(s) you don't want (by returning false or nil), and return those you do:

sig do
  params(
    array: T::Array[T.nilable(Integer)]
  ).returns(
    T::Array[Integer]
  )
end
def filter_and_double_array(array)
  integers = array.filter_map { |n| n }
  integers.map { |n| n * 2 }
end

Or in this very simple case, the two array operations can be put together:

sig do
  params(
    array: T::Array[T.nilable(Integer)]
  ).returns(
    T::Array[Integer]
  )
end
def filter_and_double(array)
  array.filter_map { |n| n * 2 if n }
end

Just like in the single value case, this works on more than just T.nilable(X) to X. Filter an array of Numeric to an array of Integer (a child type of Numeric):

sig do
  params(
    array: T::Array[Numeric]
  ).returns(
    T::Array[Integer]
  )
end
def filter_non_integers(array)
  array.filter_map { |n| n if n.is_a?(Integer) }
end

Filter an array of some union or types to a subset of the union, in this case T.any(String, Integer, Boolean) to T.any(String, Integer):

sig do
  params(
    array: T::Array[T.any(String, Integer, T::Boolean)]
  ).returns(
    T::Array[T.any(String, Integer)]
  )
end
def filter_non_integers(array)
  array.filter_map do |x|
    x if x != true && x != false
  end
end

Backlinks