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
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?
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.
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.
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.
# 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.
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:
If it’s related to a struct then in struct module
If it’s related to some specific module like when documenting API which does not need it’s struct
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 moduleshould 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.
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
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