Warning: Effeck is v0. The language will change in major, breaking ways. It is not suitable for production use.

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.1

With npm, run:

npm install --save-dev --save-exact happy-dom@20.14.6 @vue/test-utils@2.5.1

2. 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:

  1. In compilerOptions, set "rewriteRelativeImportExtensions": true. Without it, vite build fails with TS5096, because @vue/tsconfig sets allowImportingTsExtensions and the plugin emits JavaScript. A configuration without allowImportingTsExtensions fails with TS5097 instead.
  2. Add "src/**/*.effeck" to include. The template lists .ts, .tsx, and .vue globs only. Without the .effeck glob, the compiler skips .effeck files that no other file imports, such as tests, and Vitest fails with ENOENT.
{
  "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.