Skip to content

Conversation

@flupke
Copy link

@flupke flupke commented Aug 31, 2022

Hello,

First of all thank you very much for your library, it's great!

I stumbled upon this blog post explaining the Typestate pattern, and thought it was great too. Unfortunately I don't think it's possible to implement this with typed_struct because the generated t() can't have parameters.

So I tried to add a parameters option to typedstruct, but couldn't get it to work:

warning: variable "a" does not exist and is being expanded to "a()", please use parentheses to remove the ambiguity or change the variable name
  test/typed_struct_test.exs:67: TypedStructTest.ParameterizedTestStruct


== Compilation error in file test/typed_struct_test.exs ==
** (CompileError) test/typed_struct_test.exs:67: undefined function a/0 (there is no such import)
    (typed_struct 0.3.0) expanding macro: TypedStruct.__type__/2
    test/typed_struct_test.exs:67: TypedStructTest.ParameterizedTestStruct (module)
    (typed_struct 0.3.0) expanding macro: TypedStruct.typedstruct/2
    test/typed_struct_test.exs:67: TypedStructTest.ParameterizedTestStruct (module)

If you think this would be a good addition to your library, could you please have a look and nudge me in the good direction?

I feel I'm close to success but it's my first time fiddling with Elixir macros and honestly I'm lost. It looks like the problem comes from the bind_quoted: stuff in __type__() but I don't really understand what it does and couldn't find information about it, nor understand why you're using it.

Thanks,
Luper

@flupke flupke changed the base branch from main to develop August 31, 2022 20:45
Allows to add paramameters to the generated struct type.

For example:

  typedstruct parameters: [a, b] do
    field :a, a
    field :b, b | nil
    field :c, integer()
  end

Generates the type:

  @type t(a, b) :: %__MODULE__{
    a: a,
    b: b | nil,
    c: integer()
  }
@flupke flupke force-pushed the parameterized-types branch from 70c3300 to 3399d7b Compare August 31, 2022 20:46
Change examples because put_in() is not properly analyzed by dialyzer.
@ejpcmac
Copy link
Owner

ejpcmac commented Sep 1, 2022

Hello @flupke,

Indeed, this is a good idea! I’m myself using the typestate pattern in Rust, but have never used it in Elixir. I’ll try to find some time to look into your PR and debug it in the days to come, and let you know.

The compiler is currently complaining that what you are passing to the parameters: option is not valid Elixir code. Maybe this should be stated as atoms instead at this level, like in parameters: [:a, :b], and then usable as a() and b() in the field definitions.

@flupke
Copy link
Author

flupke commented Sep 1, 2022

I tried with [:a, :b] atoms at first, but trying to expand them in the t() arguments with unquote_splicing() gave errors that seemed to indicate it was expanding to t(:a, :b).

In this SO answer they seem to declare arguments names as-is. They also do the same in Ecto bindings: from [x, y] of ....

I say the error seems to come from bind_quoted: because when I print the expanded macro it looks like this:

# This
quote bind_quoted: [types: types, type_parameters: type_parameters] do
  @type t(unquote_splicing(type_parameters)) :: %__MODULE__{
          unquote_splicing(types)
        }
end
|> Macro.expand(__ENV__)
|> Macro.to_string()
|> IO.puts()

# Expands into this
types = @ts_types
type_parameters = [a, b]
@type t(unquote_splicing(type_parameters)) :: %__MODULE__{unquote_splicing(types)}

And if I remove the content of the quote block entirely I still get the same compilation error, so I think the error comes from this line:

type_parameters = [a, b]

I tried to remove bind_quoted, but then I get an error on unquote_splice(types):

== Compilation error in file test/typed_struct/plugin_type_test.exs ==
** (ArgumentError) expected a list with quoted expressions in unquote_splicing/1, got: {:@, [line: 49, context: TypedStruct, import: Kernel], [{:ts_types, [line: 49, counter: {TypedStruct.PluginEnvTest.TestModule, 3}, context: TypedStruct], TypedStruct}]}
    (elixir 1.13.4) src/elixir_quote.erl:185: :elixir_quote.argument_error/1
    (elixir 1.13.4) src/elixir_quote.erl:166: :elixir_quote.list/2
    (typed_struct 0.3.0) expanding macro: TypedStruct.__type__/2
    test/typed_struct/plugin_type_test.exs:49: TypedStruct.PluginEnvTest.TestModule (module)
    (typed_struct 0.3.0) expanding macro: TypedStruct.typedstruct/1
    test/typed_struct/plugin_type_test.exs:49: TypedStruct.PluginEnvTest.TestModule (module)

I can get the test to compile with this:

# This code
defmacro __type__(types, opts) do
  type_parameters = Keyword.get(opts, :parameters, [])

  quote do
    @type t(unquote_splicing(type_parameters)) :: {a, b}
  end
end

# Expands to
@type t(a, b) :: {a, b}

So it seems I'm going in the right direction, but I can't figure out how to get both types and type_parameters to expand correctly. If I just remove type_parameters from bind_quoted then it's not accessible from the quote block anymore.

So that's why I'm stuck, and since I have no idea what I'm doing I decided to stop trial and error and to ask for help :) I have a basic understanding of how macros work, but bind_quoted is a mystery to me, even (especially) after reading the documentation.

How did you learn all this? :)

@fahchen fahchen mentioned this pull request Sep 19, 2022
@flupke
Copy link
Author

flupke commented Sep 22, 2022

Hello, unfortunately I just learned dialyzer doesn't check type parameters and treats them as any(), which makes this feature basically useless :(

See the discussion on slack: https://elixir-lang.slack.com/archives/C03EPRA3B/p1663778934745189

More specifically: https://elixir-lang.slack.com/archives/C03EPRA3B/p1663780274897679

In this case it's not a trade-off but a deficiency in “@type” handling: arguments are plainly ignored and substituted with “any value.” Fixing this is relatively easy, but opens up a giant can of worms regarding variance (as in covariance/contravariance) and we haven't gotten around to designing a new “type” language to fix that 

And my proposed workaround: write a macro

parameterized_union do
  type a, b() | c()
  type b, e() | f(), private: true
  union generic(x, a, b), {t(), x}
end

that expands to

@type a :: b() | c()
# b is not in the output but is used in the union
@type generic_a :: {t(), b()} | {t(), c()}
@type generic_b :: {t(), e()} | {t(), f()}

I don't think this fits in typed_struct scope though...

@flupke
Copy link
Author

flupke commented Sep 23, 2022

Humm nevermind the person who told me that was wrong, parameterized types are checked just fine. My macro is completely useless and doesn't help at all, the rule just seems to be dialyzer gives up in complex situations, whatever that means.

For example these specs are checked just fine:

  @type foo :: 1 | %{a: 1}
  @type generic(x) :: {:ok, x} | {:error, x}

  @spec bar() :: generic(foo)
  def bar do
    if div(System.time_offset(), 2) == 0 do
      if div(System.time_offset(), 2) == 0 do
        {:ok, 1}
      else
        {:ok, %{a: 1}}
      end
    else
      if div(System.time_offset(), 2) == 0 do
        {:error, 1}
      else
        {:error, %{a: 1}}
      end
    end
  end

@flupke
Copy link
Author

flupke commented Sep 23, 2022

Closing in favor of #34

@flupke flupke closed this Sep 23, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants