Vue
Add Effeck to a Vue app that builds with Vite. A single-file component runs an Effeck program with a ManagedRuntime and renders three states: loading, the program's result, and an error the program recovers from. Install the packages with Add Effeck to an existing project first. Follow this page instead of Vite and Vitest: it covers the same plugin, tsconfig.json, Vitest, and build steps.
1. Install the test packages
With Bun, run:
bun add -d happy-dom@20.14.6 @vue/test-utils@2.5.1With npm, run:
npm install --save-dev --save-exact happy-dom@20.14.6 @vue/test-utils@2.5.12. Configure tsconfig.json
Find the configuration that includes src. In an app from Vite's vue-ts template, tsconfig.json has "files": [] and lists tsconfig.app.json and tsconfig.node.json as references; the one that includes src is tsconfig.app.json. Make two changes there:
- In
compilerOptions, set"rewriteRelativeImportExtensions": true. Without it,vite buildfails with TS5096, because@vue/tsconfigsetsallowImportingTsExtensionsand the plugin emits JavaScript. A configuration withoutallowImportingTsExtensionsfails with TS5097 instead. - Add
"src/**/*.effeck"toinclude. The template lists.ts,.tsx, and.vueglobs only. Without the.effeckglob, the compiler skips.effeckfiles that no other file imports, such as tests, and Vitest fails withENOENT.
{
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.effeck"]
}3. Declare .vue modules
effeck checks .ts and .effeck files and does not read .vue files. It sees a component only through a module declaration. Create src/env.d.ts. Without it, effeck -b reports TS2307 for every .vue import, such as ./App.vue in src/main.ts.
// TypeScript (.ts)
declare module "*.vue" {
import type { DefineComponent } from "vue"
const component: DefineComponent
export default component
}4. Configure Vite and Vitest
Replace vite.config.ts with this file. It adds effeck() before vue(), passes the configuration that includes src as project, and imports defineConfig from vitest/config, so the same file configures Vitest. Keep any other plugins the app has. If tsconfig.json itself includes src, call effeck() without the project option.
// TypeScript (.ts)
import vue from "@vitejs/plugin-vue"
import effeck from "effeck-vite"
import { defineConfig } from "vitest/config"
export default defineConfig({
plugins: [effeck({ project: "tsconfig.app.json" }), vue()],
test: {
environment: "happy-dom",
include: ["src/**/*.test.effeck", "src/**/*.test.ts"]
}
})5. Add an Effeck module
Create src/todos.effeck. The TodosLive layer keeps the list in a Map that its setup creates once, when the layer builds. completeTodo recovers from TodoNotFound, and loadBoard declares the contract Board!!Todos, so the compiler rejects an error that escapes it:
// src/todos.effeck
export schema Todo { id: number; title: string; done: boolean }
export error TodoNotFound { id: number }
export service Todos {
list(): ReadonlyArray<Todo>!!
complete(id: number): Todo!TodoNotFound
}
export const TodosLive = Layer.effect(Todos) {
const items = new Map<number, Todo>([
[1, { id: 1, title: "Declare the schema", done: true }],
[2, { id: 2, title: "Write the service", done: false }],
[3, { id: 3, title: "Render the board", done: false }]
])
return {
list() { return [...items.values()] },
complete(id) {
const todo = items.get(id)
if (todo === undefined) throw new TodoNotFound({ id })
const done = { ...todo, done: true }
items.set(id, done)
return done
}
}
}
export function completeTodo(id: number) {
try {
const todo = Todos.complete(id)
return `Completed: ${todo.title}`
} catch (error) {
return `No todo #${error.id}`
}
}
export interface Board {
todos: ReadonlyArray<Todo>
messages: ReadonlyArray<string>
}
export function loadBoard(): Board!!Todos {
const messages = [completeTodo(2), completeTodo(99)]
return { todos: Todos.list(), messages }
}6. Run a program from a component
Create src/runtime.ts. One ManagedRuntime builds TodosLive once and runs every program in the app:
// TypeScript (.ts)
// src/runtime.ts
import { ManagedRuntime } from "effect"
import { TodosLive } from "./todos.effeck"
export const runtime = ManagedRuntime.make(TodosLive)Create src/TodoBoard.vue. Its <script setup lang="ts"> block imports ./todos.effeck by its source name. runtime.runPromise(loadBoard()) provides Todos and returns a Promise<Board>:
<script setup lang="ts">
import { onMounted, shallowRef } from "vue"
import { runtime } from "./runtime.ts"
import { loadBoard, type Board } from "./todos.effeck"
const board = shallowRef<Board>()
onMounted(async () => {
board.value = await runtime.runPromise(loadBoard())
})
</script>
<template>
<main>
<h1>Todos</h1>
<p v-if="board === undefined">Loading…</p>
<template v-else>
<ul>
<li v-for="todo in board.todos" :key="todo.id">
{{ todo.done ? "[x]" : "[ ]" }} {{ todo.title }}
</li>
</ul>
<p v-for="message in board.messages" :key="message">{{ message }}</p>
</template>
</main>
</template>Render it from a component the app already has, such as src/App.vue: add import TodoBoard from "./TodoBoard.vue" to its script block and <TodoBoard /> to its template. The page shows Loading…, then the three todos, Completed: Write the service, and No todo #99.
7. Test the module and the component
Create src/todos.test.effeck:
// src/todos.test.effeck
import { assert, it } from "@effect/vitest"
import { completeTodo, loadBoard, TodoNotFound, Todos, TodosLive } from "./todos.effeck"
it.effect("completes a todo") {
const message = Effect.provide(() => completeTodo(2), TodosLive)
assert.strictEqual(message, "Completed: Write the service")
}
it.effect("fails with TodoNotFound for an unknown id") {
provide TodosLive {
try {
Todos.complete(99)
assert.fail("expected TodoNotFound")
} catch (error) {
assert.instanceOf(error, TodoNotFound)
assert.strictEqual(error.id, 99)
}
}
}
it.effect("recovers TodoNotFound in completeTodo") {
const message = Effect.provide(() => completeTodo(99), TodosLive)
assert.strictEqual(message, "No todo #99")
}
it.effect("loads the board after both completions") {
const board = Effect.provide(() => loadBoard(), TodosLive)
assert.deepStrictEqual(board.messages, ["Completed: Write the service", "No todo #99"])
assert.deepStrictEqual(board.todos.map((todo) => todo.done), [true, true, false])
}Create src/TodoBoard.test.ts:
// TypeScript (.ts)
import { mount } from "@vue/test-utils"
import { expect, it, vi } from "vitest"
import TodoBoard from "./TodoBoard.vue"
it("shows loading, then the board and the recovered error", async () => {
const wrapper = mount(TodoBoard)
expect(wrapper.text()).toContain("Loading…")
await vi.waitFor(() => expect(wrapper.text()).toContain("No todo #99"))
expect(wrapper.findAll("li").map((item) => item.text())).toEqual([
"[x] Declare the schema",
"[x] Write the service",
"[ ] Render the board"
])
expect(wrapper.text()).toContain("Completed: Write the service")
})If package.json has no test script, add "test": "vitest run". Run npx vitest run. The output includes:
Test Files 2 passed (2)
Tests 5 passed (5)8. Type-check and build
No type checker covers a .vue file that imports an .effeck module. vue-tsc reads .vue files with TypeScript 6, which cannot resolve .effeck modules, so the template's vue-tsc -b fails. vue-tsc 3.3.13 reports TS2307 for each import of ./todos.effeck, and TS2876 for each .vue import, because rewriteRelativeImportExtensions cannot rewrite those paths:
src/runtime.ts(4,27): error TS2307: Cannot find module './todos.effeck' or its corresponding type declarations.
src/TodoBoard.vue(4,39): error TS2307: Cannot find module './todos.effeck' or its corresponding type declarations.The gap closes when Effeck's TypeScript 7 content mapper ships and these checkers can load .effeck files. Until then, type-check with effeck -b instead. It builds the same project references and checks .effeck and .ts files as one program; it does not check the script blocks of .vue files. Keep those blocks thin: import a program, run it, and render its result, as TodoBoard.vue does. The .effeck tests and the component test cover the rest. In package.json, change the build script:
{
"scripts": {
"build": "effeck -b && vite build"
}
}Run npx effeck -b. It prints nothing and exits with status 0. Then run npm run build, or bun run build. It exits with status 0, and its last line starts with ✓ built in. Tell the user which files you added and changed, and that vue-tsc no longer runs in the build.
Next
Set up the editor: VS Code and Cursor, Neovim, Zed, or other editors. Then verify the setup with step 4 of Set up Effeck.