fuelen
What is the idiomatic shape for extensible keyword opts?
I’m working on a small library (GitHub - fuelen/mold: A tiny, zero-dependency parsing library for external payloads · GitHub) where schemas are plain data. Type options live in the 2nd element of the tuple: {:string, min_length: 2, max_length: 50}.I’d like to officially support custom keys here, so companion libraries can extend these opts.
Here’s what the typespec for :string looks like today:
@type string_type() ::
{:string,
trim: boolean(),
nilable: boolean(),
default: default(),
format: Regex.t(),
min_length: non_neg_integer(),
max_length: non_neg_integer(),
in: Enumerable.t(),
transform: transform(),
validate: validate()}
| :string
This renders well in docs. I didn’t even factor the common opts (nilable, default, transform, validate) into a shared type, because then I’d have to write:
{:string, [
{:trim, boolean()} |
{:format, Regex.t()} |
{:min_length, non_neg_integer()} |
{:max_length, non_neg_integer()} | shared_option()
]}
It renders not as nice as the inlined keyword typespec, but I’m fine with it if needed.
Technically, I can already write
{:string, min_length: 2, my_custom_opt_for_another_library: :something}
and the library swallows unknown options. But the typespec says you can’t add custom options.
Here are the options I’m considering:
Option 1. Open keyword
@type string_type() ::
{:string,
[
{:trim, boolean()}
| {:format, Regex.t()}
| {:min_length, non_neg_integer()}
| ...
| {atom(), any()} # <-- custom option
]}
| :string
In this case, the list of known opts reads more like a hint than a contract.
Probably, it would be interesting to have an ability to write something like
{custom_option_name :: atom(), any()} when custom_option_name not in [:trim, :format, :min_length, ...]
but it actually means I want a map with the syntax of keyword list ![]()
Option 2. Explicit :ext namespace
{:string, min_length: 2, ext: [some_ext_opt: ...]}
Verbose, but core opts stay strictly typed. Everything inside :ext is keyword() and available to extensions. But feels a bit artificial, like a pattern grabbed from other programming languages where type system doesn’t allow anything else.
Option 3. Just keyword()
Give up on typing opts and simply document allowed keys in @typedoc.
I think the first option is a good mix between 2nd and 3rd.
What would you pick? Is there an idiomatic shape at all?
Most Liked
mudasobwa
Correct me if I’m wrong, but you asked about the idiomatic solution. I honestly don’t know what would be more idiomatic than the language core. It accepts keyword() for convenience, but it immediately raises when keys are not known.
That is detectable by the typing system, unlike map().
GrammAcc
Disclaimer: I’m still learning, so not an Elixir expert.
This second option seems the best to me. Even if the type system can make sense of a big bag of atoms, having things namespaced like this makes things easier to reason about for everyone as long as it’s not nested imo.
Another option would be to allow the user to register their custom options with the lib via a macro, so that all options can be treated the same and verified at compile time by checking them against the list of registered atoms.
fuelen
Core doesn’t actually raise on unknown keys here. Both inspect/2 and String.split/3 just ignore them:
iex> inspect(1, my_option: 1)
"1"
iex> String.split("Hello World", " ", test: 1)
["Hello", "World"]
custom_options option was only added in 1.9.0. The struct itself existed long before that. A struct is not an open map by definition, so when the need for caller-supplied extras showed up, the only place to put them was a separate custom_options field. So I wouldn’t read it as evidence that the closed/struct approach is the idiomatic one. There wasn’t really a choice given the existing shape.
Logger is in the standard library too, and it goes the other way from Inspect.Opts. It uses a single flat keyword for everything. Name collisions are possible, but they’re handled by convention and documentation, not by validation or a separate field.
Logger.info("hello", ansi_color: :green, whatever: 1)
That’s more about reading, rather than typing.
I wouldn’t say map is correct in this case and keyword isn’t. Theoretically, there might be options in the future that’ll benefit from duplicate keys ![]()
You forgot about wrapping type to a list
[EDIT: I misread shared options as open options and came to this example] With shared options it makes sense to use parameterized open_keyword type:
@type open_keyword(t) :: [t | {atom(), term()}]
@type string_option :: {:trim, boolean()} | ...
@type shared_option :: {:nilable, boolean()} | ...
@type string_type :: :string | {:string, open_keyword(string_option() | shared_option())}
Actually… it looks nice ![]()
Yeah, I know Req library uses this approach. But Mold positions itself as a lightweight, shape-first library where schemas are plain data and ergonomics matter more than catching mistyped option names – String.split/3 doesn’t check options and that’s not the end of the world.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









