# Declaring opaque return types on constructors

Our Phoenix browser tests use PhoenixTest.Playwright to drive a real browser. Our additional-session helper asks the library to create a session, then passes that session to `unwrap/2` for access to its browser identifiers.

That worked in browser tests, but Dialyzer reported `call_without_opaque` when we passed the session back to the library. The function creating it was missing a return spec. Inside PhoenixTest.Playwright, the existing type declaration and the added spec look like this:

```elixir
# Already declared in the library:
@opaque t :: %__MODULE__{}

# Added above the existing build/1 definition:
@spec build(map()) :: t()
```

The added line declares that `build/1` returns the library’s session type, named `t()`.

A session is stored as a `%PhoenixTest.Playwright{}` struct. Declaring its type with `@opaque` tells Dialyzer that the struct’s representation belongs to the library: application code should receive sessions and use the library’s functions rather than depend on their fields. `unwrap/2` is one of those functions, providing supported access to browser identifiers.

The missing piece was how Dialyzer identified the value we received. In [version 0.19.0](https://github.com/ftes/phoenix_test_playwright/blob/v0.19.0/lib/phoenix_test/playwright.ex), `build/1`, the constructor that creates a session, returned the struct without declaring its return type. Our helper received that result through `Case.new_session/2`.

So when the helper passed it to `unwrap/2`, Dialyzer had inferred a concrete struct, while `unwrap/2` expected the library’s opaque session type. The struct had the right fields, but analysis was missing the declaration identifying the constructor’s result as that type. That is why the warning appeared at a supported library call even though the browser tests passed.

Adding `@spec build(map()) :: t()` to the constructor supplied that declaration. The part after `::` says what the function returns; it changed the information available to Dialyzer without changing the struct or browser behavior. The `map()` input is broad here to keep the example focused; a complete input spec can describe the required keys too.

The annotation belongs inside the module defining the opaque type. An application helper that constructs the struct itself cannot establish the same contract merely by adding a return spec. This case was a missing constructor declaration; the same warning can also indicate that application code is actually constructing or inspecting an opaque value. Opacity is checked by analysis tools and does not prevent field access at runtime.

[Elixir typespecs: user-defined types](https://hexdocs.pm/elixir/typespecs.html#user-defined-types)

The fix is now proposed upstream in [PhoenixTest.Playwright PR #259](https://github.com/ftes/phoenix_test_playwright/pull/259).


---

Created by: almirsarajcic
Date: October 24, 2026
URL: https://elixirdrops.net/d/BVAuGJdf
