Best practice for reusing typespecs

  1. If we want to introduce common reusable type, should we introduce separate Types module like so?:
defmodule App.Shared.Types do
  @moduledoc """
  Common reusable types
  """

  @type generic_id :: non_neg_integer() | String.t()
end

  1. Also if we have a struct type defined for some entity, and then we want to reuse a type of a field from that struct — can we directly refer to the field type using something like Struct.t().field() (this and [:field] notation does not work though)? Or reusing field types like this is impossible and we need to define custom public type within a struct module, and then reuse this type for a struct type and in other modules?

Your idea sounds oddly familiar to inheritance (correct me if I am wrong).

From my observation of bigger projects, reusing your types makes them clearer and more readable.

Try to imagine similar way of thinking … Would you like to write dozens or hundreds of Ecto.Schema just to have them in one place? For sure there is no rule to cover all edge cases, but in general we rather avoid such practices. :thinking:

Firstly all struct related types should be defined in struct’s module. It’s no shame to have a public type for a single field just to reuse said type somewhere else as long as we speak about same data i.e. it’s fine to use field’s type somewhere in code especially when we write typespec for private functions for dialyzer tool or so. :see_no_evil:

However using same type just because it’s typespec matches specific needs (like a generic keyword() type) is incorrect as developer working with your library would think that only data from said struct are seen as valid / supported. :crazy_face:

# Here we have struct and all related typespec in one place.
# Structs may be big for sure, but definitely not like all types in one place.
defmodule MyLib.SomeStruct do
  # …

  defstruct # …

  @typedoc "…"
  @type t :: %__MODULE__{
    some_field: some_field(),
    # …
  }

  @typedoc "…"
  @type some_field :: # …

  # …
end
# Generally I recommend to write types as high as possible
# i.e. it would be weird if a parent module would use a type from sub module
# as the data is not strictly related to anything (like in structs)
# so the type should be defined in highest parent module which uses it
# or a parent module which groups sub modules using said type
defmodule MyLib.Utils do
  # …

  @typedoc "…"
  @type commonly_used_data_type_not_related_to_any_struct :: # …

  # …
end

In example above I would move the type definition from MyLib.Utils to MyLib if MyLib or MyLib.OtherModule would use it. Otherwise it’s in good place. :+1:

Of course there may be an edge case. Let’s say that you document an API and then Utils (more generic code) would use types from it.

defmodule MyApp.Utils do
  # …

  @spec some_func(MyApp.API.V1.APIObjectNotRelatedToAnyStruct.type_name()) :: # …

  # …
end

That’s why there is no one rule to write typespec. Generally you put types in order:

  1. If it’s related to a struct then in struct module
  2. If it’s related to some specific module like when documenting API which does not need it’s struct
  3. In top Utils-like module as in above example
1 Like

I am not sure if we’re talking actual types or the fields themselves?

If it’s just the types, that’s easy enough. Following your example the user module would look like this:

defmodule App.UserSchema do
  alias App.Shared.Types

  @type user_result :: {:ok, Types.generic_id()} | {:error, any()}
end
2 Likes

I would encourage you to think hard about what module should own a type before placing it in a catch-all drawer. Typespec types are used at function boundaries: inputs and output. Modules tend to group functions around specific input/output themes. So if you have a type that doesn’t feel like it has a clear module owner, you probably have a leaky abstraction popping up all over your code base, involved in perhaps too many function input/outputs. It would suggest you are missing a module to handle that type in particular.

Of course that is idealistic and impractical in many applications. I encourage placing these sorts of global types at the toplevel module of your project; ex: App.generic_id. If they are truly global to your domain, they will read well there. Otherwise, as you carve out a niche for them in future modules it will be easy to shift their location.

Correct, you cannot index into struct type’s field types. What you propose instead is fine, flexible, and common:

defmodule App.Entity do
  defstruct [:id, :data]
  @type id :: non_neg_integer() | String.t()
  @type data :: any()
  @type t :: %__MODULE__{
    id: id,
    data: data
  }
end

Again this idea of which module should own the type comes up. In the example above, we’re saying that App.Entity really owns what an id even means, and other modules referencing that type are using its understanding. You could just as easily define (for example) an App.id type and use it inside the App.Entity.t if you think that somebody else really determines what a functioning id should look like in your system.

4 Likes

Not sure about inheritance, but since we only can define types within modules, means we need to use modules as containers for types

My intent is to only write truly reusable things in one place, maybe meaningfully grouped in multiple places, in order to prevent “bad” duplication (aka knowledge duplication), as opposed to “ok”/coincidental duplication, which I’m ok with. So it seems like these types are better reside somewhere meaningful

E.g. when I work with data which has specific shape/types defined for this data, then I handle this data in some other place — it is useful to be able to refer to a type of a specific field for this data and not to duplicate the type and then keep it in sync

For when to write typespecs — I figured it’s a whole new topic, which I’ll investigate further, e.g. I read it is recommended to only write typespecs for public functions

That’s practically what I imagined

Actual types. This is what I expected, but wanted to confirm. The 2nd case is more vague though: we already export struct type, but we can not refer to a field type of this struct, this requires defining it in a separate public type near this struct (but maybe in practice it is not an issue at all)

Great points! For sure I’ll be keeping details to where they belong

2 Likes