How do you check that a MapSet is a MapSet?

I have a function where I do:

def recomputeCapacity(%MapSet{} = mCallIds) do
    cu =
      mCallIds
      |> MapSet.to_list()

And Dyalizer says that the function can never complete (and all the functions calling it…) because:

The call 'Elixir.MapSet':to_list
         (_mCallIds@1 :: #{'__struct__' := 'Elixir.MapSet', _ => _}) does not have an opaque term of type 
          'Elixir.MapSet':t(_) as 1st argumentElixirLS Dialyzer

Now, if mCallIds was an integer or something I’d use a guard is_integer(), but how do I make sure that only a MapSet can match this function?

I could have a

@spec recomputeCapacity( MapSet.t(any) ) :: number

But it would not prevent me calling the function by mistake, and if I leave the %MapSet{} = mCallIds Dialyzer keeps complaining.

I know that MapSet is opaque so I should not pattern-match on it (though - annoyingly - it works…) but what is the alternative?

2 Likes

This is a good question. The pattern match you are doing, on %MapSet{}, is indeed the correct way to ensure that only MapSet-structs can be passed to the function. It is OK to patternmatch on it. It is only wrong to match on any particular fields the struct may or may not have (as these may change in future versions without warning).
This is the same for all opaque structs (and in this case mentioned explicitly at the end of the module’s documentation as well.)

Furthermore, your spec, using MapSet.t(any), is correct. It is an opaque type (so you’re not supposed to depend on its details), but internally it also is the same as a %MapSet{}-struct.

I’m not immediately seeing where the problem of Dialyzer comes from. What you could try, is to just use the type MapSet.t() instead of MapSet.t(any). It means the same, but maybe it confuses Dialyzer less in this situation?

It looks like it’s the match that Dialyzer won’t like, not just in the function signature - this won’t work as well:

  @spec recomputeCapacity(MapSet.t()) :: number
  def recomputeCapacity( mCallIds)  do

    %MapSet{} = mCallIds

    current_capacity_used =
      mCallIds
      |> MapSet.to_list()

Is this a bug? do I just switch checks off for now?

I wonder if using when is_struct(mCallIds, MapSet) results in the same warning?

:thinking: It seems like this is a known limitation of Dialyzer

What I said about pattern-matching on a struct that is defined as an opaque type is correct in theory, but in practice it seems that Dialyzer does not follow this logic correctly in certain situations.

To be precise: An opaque type hides even the fact whether something is a struct or not. (The Erlang/Dialyzer type system, which Elixir inherited, does not treat structs differently from maps.) Thus the pattern match is not strictly allowed. This makes the MapSet.t type impossible to use in combination with any pattern-match.

Personally I’d keep the runtime check (because it is enforced rather than opt-in), and change the @spec to use %MapSet{optional(any) => any()} instead.

2 Likes

Very much this. Opaque types are meant to be treated as completely opaque. It could be a struct today and a tuple tomorrow. Therefore even checking for the struct type is a violation of the opaqueness. It’s unlikely for such a change to happen for MapSet, but that’s besides what dialyzer tries to assert.

Just for an example: ets at some point changed the underlying datatype for :ets.tid. So changes like switching even the underlying datatype do indeed happen sometimes.

1 Like

I agree - but shouldn’t an opaque type offer an is_me?() method, so that you can know that it is itself?

Yes it does.

So you’d simply switch off the warning?

Maybe. The problem with MapSet imo is less how opaque types work though, it’s that MapSet is not actually meant to be a full opaque type.

MapSets being a struct is a stable contract. Only all the other keys on the map are considered implementation details. That’s a usecase dialyzer is not able to handle – partial opaqueness in maps per key. If MapSets would be a opaque datastructure in the sense dialyzer treats it then you’d never see any documentation matching on it being a struct in the first place or any other reference to it’s underlying datatype.

E.g. I’d never check if a tid is a valid ets tid. I don’t even know what to check it by. I just use it as one and if it blows up it might have not been.

3 Likes

No, I’d rewrite the @spec to use a type different from MapSet.t() to circumvent the limitation in Dialyzer.

So instead of using the opaque type

@spec recomputeCapacity( MapSet.t(any) ) :: number

, using the non-opaque type

@spec recomputeCapacity(%MapSet{optional(any) => any}) :: number

Depends on what you mean by “calling” - the call to MapSet.to_list/1 would fail on exactly the same kind of MatchError that the pattern-match in your function would, so the result would be the same:

Like @LostKobrakai said, sometimes you don’t need to check every argument exactly at every layer. Let it crash.

1 Like

However, a MatchError will look like a mistake in the code of the module, whereas an ArgumentError or FunctionClauseError (usually) indicates a mistake in the calling code.