Specifying map or struct extension using typespecs

Hi,

Is there any way to specify a map or a structure extension using typespecs, e.g.:

@type map1() :: %{
  field1: String.t(),
  field2: atom()
}

@type map2() :: %{
  map1() |
  field3: integer(),
  field4: integer()
}

Naturally, the above wouldn’t compile, but is there any way to achieve the same effect? If not, how about adding this feature to typespecs?

TIA

Not really the answer You are looking for, but maybe typecheck can help…

1 Like

In Elixir’s typespec syntax itself, this is not possible. There is no notion in Elixir/Erlang’s typespecs of ‘a couple of keys’ that you add to the specification of another map.

You have the choice of adding one or multiple optional(key_type) => value_type() at the end (where key_type might be a single atom), but a key-value pair like this (e.g. optional(key_type) => value_type() ) does not have a type on its own and cannot be embedded on its own.

Since Elixir’s typespecs have feature-parity with Erlang’s typespecs (there is a little bit of extra syntactic sugar but they are 100% compatible) I do not think a feature like this will ever be added.

As @kokolegorille already pointed out, there are ways to add (runtime) type-checks to your code that might be able to do checks like this.
Currently in the TypeCheck library the notion of a key-value pair also does not exist as its own structure (it is an interesting feature though, PR’s are very welcome :upside_down_face: ), but it is fully possible to build it yourself in user code and integrate it with the rest of TypeCheck.

1 Like

The best you can do in Elixir itself, is something crazy like this (I do not recommend doing this!):

defmodule ExtendedMap do
  @duplicated_types (
    quote do
      [
        field1: String.t(),
        field2: atom()
      ]
    end)

  @type map1() :: %{
    unquote_splicing(@duplicated_types)
  }

  @type map2() :: %{
    unquote_splicing(@duplicated_types),
    field3: integer(),
    field4: integer()
  }
end

So we are able to inject type snippets into other types using quote/unquote.
However, since most types are not valid as values, we also need to have the extra quote around the list. Also, the parentheses around this quote are not optional, because of a parsing precedence edge case in Elixir, making it even more clear that we’re in unexplored waters here: You’re not intended to write this kind of code.

So: This seems to me a clear situation in which the ‘cure’ is worse than the ‘disease’.

2 Likes

The cure indeed looks worse than the disease, but thank you for going into length with this.

Erlang feature-parity be damned, I still think such a basic tool for reusing base structure fields elsewhere should be made available. For instance, my “defextends” macro helps me “extend” elixir structures without needing to copy the “inherited” fields and it proved to be very useful, but I still lack a tool for writing the typespecs.

As for TypeCheck, I don’t think it addresses this problem at all.

Thanks

In that case I might be misunderstanding your use case. I thought your scenario was that you want to extend the typespec of one struct with some of the fields of the typespec of another struct. TypeCheck is able to facilitate that, and create the final ‘Elixir-compatible’ typespecs to be shown in the documentation and e.g. passed to Dialyzer for you.
But if I’m not correctly paraphrasing your scenario, please do explain :blush:.

1 Like

I took a quick look at the TypeCheck docs and I couldn’t find any mention of extending/inheriting from a structure or a map. Can you please provide a snippet of how it may be done using TypeCheck?

Thanks

Thank you for this question! I attempted to do it, but I did encounter a problem.
In essence, creating functions that take TypeCheck type-structs as input and generate other type-structs as output is ‘easy’:

  def map_union(lhs, rhs) do
    lhs_keypairs = extract_keypairs(lhs)
    rhs_keypairs = extract_keypairs(rhs)

    TypeCheck.Builtin.fixed_map(TypeCheck.Builtin.fixed_list(lhs_keypairs ++ rhs_keypairs))
  end

  defp extract_keypairs(list) when is_list(list) do
    list
  end

  defp extract_keypairs(list = %TypeCheck.Builtin.FixedList{}) do
    list.element_types
  end

  defp extract_keypairs(map = %TypeCheck.Builtin.FixedMap{}) do
    map.keypairs
    |> Enum.map(fn {key_atom, val_type} ->
      TypeCheck.Builtin.fixed_tuple([TypeCheck.Builtin.literal(key_atom), val_type])
    end)
  end

and indeed, if we were to add this to the TypeCheck.Builtin module, it would work as intended, and you could e.g. write:

defmodule ReusedKeys do
  use TypeCheck

  @type! map1 :: %{field1: binary(), field2: binary()}
  @type! map2 :: map_union(map1, [field3: binary()])
end

However, currently there is no easy way for a user to make their own custom “type-level” functions available for usage in the types, as type specifications are evaluated in a slightly special context because of implementation reasons.

I’ve created an issue on the TypeCheck repository to tackle this.

So to answer your question: No, it is currently not possible, but will be "soon :tm: ".

1 Like

Thanks! Will be glad to check it out when you add the feature. Please, make the use as neat as possible.

Another thing. As you can imagine, the requirement for a map/struct extension often regards inter-module references e.g.:

defmodule Base do
  @type t() :: %__MODULE__{
                 field1: binary(),
                 field2: binary()
               }
  defstruct field1: nil,
            field2: nil
end

defmodule Extension do
  @type! t() :: subtype( %__MODULE__,
                         Base.t(), # may be a list if multiple extension
                         field3: integer(),
                         field4: integer())
  defextends Base,
             field3: 1,
             field4: 0
end