Makes the props contract between <.vue ...> in LiveView and defineProps<T>() in Vue red on
mix compile / mix test instead of in production.
LiveVue passes data from LiveView to a Vue component as props.
The shape of that data is written twice: implicitly on the Elixir side (whatever ends up in the
assign) and explicitly in TypeScript (defineProps<{ units: Unit[]; total: number }>()). Nothing
checks one against the other: rename a key in the presenter and both compilers stay silent, tests
stay green unless one asserts that exact key, and Vue renders undefined as an empty cell.
This library takes defineProps<T>() as the single source of truth and checks the Elixir side
against it. Nothing is described a second time.
| Drift | Caught at |
|---|---|
<.vue> does not pass a required prop | mix compile |
<.vue> passes a prop the component does not declare | mix compile |
v-component="UnitLst" — typo, or an ambiguous name | mix compile |
presenter renames or drops a key, at any depth (items[].statusLabel) | mix test, dev runtime |
string where TS says number, null where TS says non-nullable, value outside a union of literals | mix test, dev runtime |
wrong value inside a Record<string, T>, an index signature, Partial<T>, Pick<T, K>, a recursive type | mix test, dev runtime |
| server sends a key the TS type does not know | warning in the log (strict: true makes it an error) |
"mix test" means: any live test that renders the screen. "dev runtime" means: the first render in
mix phx.server raises with the JSON path of the problem.
How it works
.vue (defineProps<T>) --vue-component-meta--> assets/vue/contract.json (Mix compiler, by source hash)
│
┌─────────────────────────────────┴───────────────────────────────┐
~H at compile time: attributes of <.vue v-component="X"> ⟷ keys of schema X vue/1 at render (dev/test):
missing required / undeclared / unknown component → CompileError encoded props ⟷ nested schema
→ ArgumentError with a JSON path- A Mix compiler (
:live_vue_contract) runsvue-component-meta(the same Volar enginevue-tscuses) over every.vuefile to list its props, builds a JSON-Schema subset from their TypeScript types and writes it toassets/vue/contract.json. Named types (Author,Record<string, Author>) are written once under"$defs"and referenced, so shared and recursive types cost one entry. It reruns only when a.vuefile, a file a prop type is declared in, or the generator itself changed. use LiveVueContractoverrides~Hand checks the module's.heexfiles (embed_templates, a LiveView's colocated template) the same way. After the template is compiled as usual, the HEEx AST is walked and every literal<.vue v-component="X" ...>(and every<.X>shortcut) is compared with the schema ofX. The walk depends on what Phoenix LiveView compiles a function component to; the tags are also counted in the template source, and a template where the walk found fewer is aCompileError, so a LiveView release that changes that form breaks the build instead of turning the check off.LiveVueContract.vue/1replacesLiveVue.vue/1. In dev and test it encodes the props exactly as LiveVue would and validates them against the nested schema before rendering. In a release it is a pass-through.
Installation
# mix.exs
def project do
[
...,
compilers: [:live_vue_contract, :phoenix_live_view] ++ Mix.compilers()
]
end
defp deps do
[
...,
{:live_vue_contract, "~> 0.3"}
]
end# lib/my_app_web.ex, inside html_helpers — replaces `use LiveVue` and `use LiveVue.Components`
use LiveVueContractnpm install -D vue-component-meta@$(node -p "require('vue-tsc/package.json').version")
mix compile # writes assets/vue/contract.json
git add assets/vue/contract.json
vue-component-meta must be the same version as your vue-tsc; both come from the Volar monorepo.
If your html_helpers imported LiveVue.SharedPropsView (shared props, automatic v-socket),
keep that behaviour with:
use LiveVueContract, shared_props: trueConfiguration
config :live_vue_contract,
vue_root: ["assets/vue"], # where the components live, like LiveVue.Components
watch: [], # extra globs that regenerate the contract (prop types are tracked)
contract: "assets/vue/contract.json",
tsconfig: "tsconfig.json",
validate: true, # render-time validation; default: on under Mix outside :prod
strict: false # extra nested keys: warning (false) or error (true)use LiveVueContract options: shared_props: true (compile through LiveVue.SharedPropsView),
shortcuts: false (do not define <.ComponentName> functions).
Rolling out on an existing project
The first run on a real project usually finds drift you did not know about. To keep the suite green while you fix it:
# config/test.exs, config/dev.exs
config :live_vue_contract, validate: :warnEvery mismatch is then logged once per component and key instead of raising. Remove the line when
the log is clean. strict: true turns "server sends a key the type does not know" into an error too.
CI and Docker
- What the contract was built from (hash and list of source files) is kept in a stamp under
_build/<env>, not in the contract. Node runs only when a.vuefile or a file a prop type is declared in changed; the contract is rewritten only when the schemas changed, so~Hmodules are not recompiled for edits that do not touch props. - Without
node: a committedassets/vue/contract.jsonis used as is (with a note) when_buildhas no stamp; a missing contract failsmix compilewith a message saying so. - To assert freshness in CI where
nodeis available:mix compile && git diff --exit-code assets/vue/contract.json. - If your Dockerfile runs
mix compilebeforeCOPY assets(the Phoenix default), there are no.vuefiles at that point and the compiler is a no-op; the latermix releasesees the committed contract and checks against it.
What it does not catch
- Screens nobody renders in a test. Nested-value checks (renamed key, wrong type) run at render time, so a root component without a live test gets only the compile-time attribute check.
- Values your tests never produce. The render-time check sees the data the test rendered: a
nilbranch, a rare enum value or an empty list that only production data has goes unchecked. It is off in releases. - Dynamic tags.
v-component={@name}and<.vue {@rest}>are skipped at compile time and checked at render time only. - TypeScript's lib types and functions.
Date,HTMLElement,Map,Promise,unknown,objectand function types becomeany: none of them is a JSON shape. (Mapped and generic types —Record,Partial,Pick, your own generics — are expanded.) - Distinctions JSON does not make.
DateandDateTimeare bothstring;integerandfloatare bothnumber. - Events.
pushEvent/handle_eventandpush_event/useLiveEventare not covered.
Requirements
- Elixir 1.18+ (uses the built-in
JSONmodule), Phoenix LiveView 1.1+, LiveVue 1.2+. - Node.js with
vue-component-metaandtypescriptin the host project'snode_modules, only on machines that regenerate the contract.
CI runs the oldest supported versions (Elixir 1.18, LiveView 1.1, LiveVue 1.2.0), the locked ones, and the newest on Hex — the last also weekly, so a LiveView release that changes compiled HEEx shows up here first.
Development
mix deps.get
npm ci --prefix test/fixtures/app # generator tests; skipped without it
mix test
test/fixtures/app is a small Vue project whose defineProps cover every shape the generator maps;
its expected contract is checked in (contract.expected.json). After a deliberate change to the
generator: LIVE_VUE_CONTRACT_UPDATE=1 mix test and review the diff.
The Elixir tests cover the schema validator and name resolution. The generator is exercised against
real projects; see CHANGELOG.md for what it was verified on.
License
MIT