FlowFuse / FlowFuse/flowfuse

TypeScript Migration

Open
#6,893 3 comments 5 reactions 1 assignee Claimed by @n-lark View on GitHub
Dominant language
JavaScript
Stars
400
Forks
89
Avg merge
1d 20h
Merged PRs (30d)
149

Description

# TypeScript Migration Proposal — FlowFuse

## TL;DR

**The problem is already in production.** Sentry recorded **440 TypeError events across 29 frontend issues in 90 days** — all `Cannot read properties of undefined/null`. ~390 of those are the exact category TypeScript catches at write-time, before a PR is opened.

**No feature freeze, no big project.** Two rules: new files are `.ts`, existing files convert when touched. Plus a few weeks of targeted work on API modules, stores, and composables after Pinia wraps up.

**Timeline:** ~9 weeks calendar time at 20% sprint capacity (~1.5–2 weeks actual engineering). 507 Vue components convert on touch — no sprint needed.

**This is not a new language.** Same runtime, same tests, same `node_modules`. Existing `.js` files work untouched.

---

See extensive proposal here

## Why Now?

**The Pinia migration shows exactly why this matters.** Refactoring stores without types means manually tracing every callsite, hoping nothing was missed, and finding out in QA or production if it was. We're living that right now. TypeScript makes that class of refactor auditable — the compiler tells you what broke, not a user.

**The cost goes up the longer we wait.** Every new `.js` file written today is another file on the conversion pile. 507 components now — more next quarter. Phase 1 (tooling) is 2 days and zero-risk. There is no upside to delaying it.

**These errors are a rate, not a snapshot.** 440 TypeError events in 90 days means ~5 per day reaching users. The product is growing. More surfaces, more of the same errors, indefinitely — unless the underlying pattern is fixed.

**The "right time" doesn't arrive on its own.** After Pinia there will be something else. This is always true. The ask here is small: one tooling PR now, targeted conversion work when Pinia wraps up.

---

## Real Production Impact

These are not hypothetical. The following errors appeared in Sentry over the last 90 days — all `TypeError: Cannot read properties of undefined/null`. Every one of them is the category of bug TypeScript catches at write-time.

### `Cannot read properties of undefined` (10 issues)

| Sentry Issue | Reading | Location | 90d Events | 90d Users | TS verdict |
|---|---|---|---|---|---|
| FLOWFUSE-FRONTEND-N8 | `getBoundingClientRect` | `TeleportedMenuMixin` | 139 | 29 | Partial — typed `$refs` enforce null checks |
| FLOWFUSE-FRONTEND-P1 | `getBoundingClientRect` | `app/main` | 54 | 17 | Partial — typed `$refs` enforce null checks |
| FLOWFUSE-FRONTEND-88 | `status` | `device/index` `pollTimerElapsed` | 16 | 6 | **Caught** — `err.response` typed as `AxiosResponse \| undefined` |
| FLOWFUSE-FRONTEND-RC | `push` | `checkAccess` | 8 | 2 | **Caught** — `this.team` typed as `Team \| null` |
| FLOWFUSE-FRONTEND-PP | `prototype` | `Array.forEach` | 7 | 6 | **Caught** — typed array items |
| FLOWFUSE-FRONTEND-RD | `push` | `checkAccess` | 4 | 2 | **Caught** — same as RC |
| FLOWFUSE-FRONTEND-QH | `status` | `pollTimerElapsed` | 3 | 3 | **Caught** — same as 88 |
| FLOWFUSE-FRONTEND-PQ | `prototype` | `Array.forEach` | 3 | 3 | **Caught** — same as PP |
| FLOWFUSE-FRONTEND-JY | `accounts` | `vue3-google-login` | 2 | 2 | Not caught — third-party module |
| FLOWFUSE-FRONTEND-P0 | `data` | `AdminUserEditDialog` | 1 | 1 | **Caught** — typed error response shape |
| **Subtotal** | | | **237** | **71** | |

### `Cannot read properties of null` (19 issues)

| Sentry Issue | Reading | Location | 90d Events | 90d Users | TS verdict |
|---|---|---|---|---|---|
| FLOWFUSE-FRONTEND-85 | `subscribe` | `DeviceLog` | 75 | 34 | **Caught** — typed as `T \| null` forces guard |
| FLOWFUSE-FRONTEND-9Q | `id` | `team/Instances` `fetchData` | 47 | 28 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-PV | `application` | `device/Settings` `checkAccess` | 18 | 7 | **Caught** — `device.application: Application \| null` |
| FLOWFUSE-FRONTEND-MQ | `id` | `operator-agent` `getCapabilities` | 16 | 14 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-RA | `subscribe` | `app/main` | 15 | 11 | **Caught** — typed as `T \| null` forces guard |
| FLOWFUSE-FRONTEND-R0 | `id` | `connectMQTT` | 8 | 4 | **Caught** — typed as `T \| null` |
| FLOWFUSE-FRONTEND-R3 | `id` | `fetchData` | 4 | 3 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-N2 | `id` | `operator-agent` `getCapabilities` | 3 | 3 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-ES | `classList` | `Tooltip` directive | 3 | 2 | **Caught** — `querySelector` returns `Element \| null` |
| FLOWFUSE-FRONTEND-B3 | `id` | `fetchData` | 3 | 3 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-Q3 | `id` | `DeviceLog` `connectMQTT` | 2 | 2 | **Caught** — typed as `T \| null` |
| FLOWFUSE-FRONTEND-RP | `id` | `fetchData` | 1 | 1 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-RN | `id` | `fetchData` | 1 | 1 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-RF | `id` | `fetchData` | 1 | 1 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-QF | `close` | `device/index` | 1 | 1 | **Caught** — typed as `T \| null` |
| FLOWFUSE-FRONTEND-QE | `id` | `getCapabilities` | 1 | 1 | **Caught** — `team: Team \| null` |
| FLOWFUSE-FRONTEND-3F | `subscribe` | `app/main` | 1 | 1 | **Caught** — typed as `T \| null` forces guard |
| FLOWFUSE-FRONTEND-N3 | `clear` | `FileBrowser` | 1 | 1 | **Caught** — typed as `T \| null` |
| FLOWFUSE-FRONTEND-MV | `getBoundingClientRect` | `Array.forEach` | 1 | 1 | Partial — typed refs enforce null checks |
| **Subtotal** | | | **203** | **119** | |

### Combined

| | Events | User-issue instances |
|---|---|---|
| `undefined` errors | 237 | 71 |
| `null` errors | 203 | 119 |
| **Total** | **440** | **190** |

**Of these 440 events, ~390 (across 26 issues) are definitively caught by TypeScript's strict null checks** — they are all property access on values TypeScript would type as `T | null` or `T | undefined`, which requires a guard before the code compiles.

The `getBoundingClientRect` errors (194 events across 3 issues) are DOM timing issues. TypeScript doesn't eliminate them outright, but typed `$refs` in `` make the unsafe access patterns visible at write-time rather than in Sentry.

The most striking pattern in the null errors: **seven separate `reading 'id'` issues all trace back to `this.team` or `this.device` being null mid-load**. A single `Team | null` type on the store getter catches every one of them.

---

## The Case for TypeScript

### 1. Your IDE becomes a collaborator, not just a text editor

Without types, your editor can only tell you what methods exist on built-in JavaScript types (strings, arrays, etc.). With TypeScript, it knows the shape of *your* objects — the fields on a `Device`, the props a component expects, the return value of an API call.

What this looks like in practice:
- You type `device.` and autocomplete shows you every field — no more guessing or jumping to the API file to remember the shape
- You rename a prop and every component that uses it is flagged instantly, not discovered in QA
- You pass an `applicationId` where a `teamId` is expected and the editor underlines it before you save

### 2. Bugs get caught at write-time, not in production

The bugs TypeScript prevents are not exotic edge cases. They are the boring, high-frequency mistakes:

```js
// JS: This silently passes undefined into a component
const team = teams.find(t => t.id === teamId)
updateTeamName(team.name) // team could be undefined — no warning
```

```ts
// TS: Editor flags this immediately
const team = teams.find(t => t.id === teamId) // type: Team | undefined
updateTeamName(team.name) // Error: 'team' is possibly 'undefined'
```

For a product like FlowFuse where users manage production Node-RED instances, a `Cannot read property of undefined` error has real consequences.

### 3. Refactoring becomes safe

Right now, changing what an API module returns or renaming a prop requires manually tracing every usage — and hoping you found them all. With TypeScript, the compiler does that trace for you and won't let you ship until every callsite is updated.

This is especially relevant as we migrate stores from Vuex to Pinia. TypeScript makes those refactors auditable rather than faith-based.

### 4. Onboarding is faster

New engineers (and the person who wrote a component six months ago) don't need to read the entire call chain to understand what a function or component expects. The types *are* the documentation, and unlike comments, they can't go stale — the compiler enforces them.

### 5. TypeScript is JavaScript

This is the most common misconception. TypeScript compiles to plain JavaScript. You keep the same runtime, the same `node_modules`, the same test infrastructure. You are not switching languages — you're adding a layer of checking on top of the language you already use.

You can adopt it gradually. A `.ts` file can import from a `.js` file and vice versa. You can turn on strict checking incrementally. You never have to touch a file just because it's `.js`.

---

## The Migration Strategy: Incremental, No Freeze

**Scope: frontend only** (`/frontend/src`). The backend (`/forge`) is out of scope for now.

### Sequencing: TypeScript After Pinia

**We will not begin proactive file conversions (Phase 2) until the Pinia migration is complete.**

Reasons:
- Stores are actively being rewritten. Converting a Vuex module to TypeScript now means converting it twice — once to TS, once to Pinia.
- TypeScript and Pinia work best together. A `defineStore` with `ref<Team | null>(null)` gives you full type inference for free. Doing that conversion in one PR is more efficient than two.
- Keeping the migrations sequential reduces noise in PRs and makes each change easier to review.

**What this means in practice:**
- Phase 1 (tooling PR) can land at any time — it doesn't touch source files and has no conflict with Pinia work.
- The Two Rules (new files in `.ts`, convert on touch) apply immediately after Phase 1 merges.
- Phase 2 proactive conversions begin as a sprint task once Pinia is wrapped up.

### The Short Answer on Time

Using LLMs to assist with file conversion, the total upfront investment drops to **~1.5–2 weeks of actual engineering time**, spread across roughly **6–7 weeks of calendar time** at ~20% of sprint capacity after Pinia completes. No dedicated sprint, feature work never pauses.

The LLM does the mechanical work — renaming, rewriting props, inferring types from usage. The engineer reviews, fixes edge cases, and merges. Per-file conversion goes from 30–60 minutes to 5–15 minutes.

```
[Pinia migration completes]
Week 1 ████░░░░░░░░ Phase 1: Tooling (~2 days, one PR) ← can land before Pinia done
Week 1–2 ████░░░░░░░░ Phase 2a: Domain types via openapi-typescript (~1 day)
Week 2–4 ████░░░░░░░░ Phase 2b: API modules — LLM converts, engineer reviews (~half day/week)
Week 3–5 ████░░░░░░░░ Phase 2c: Pinia stores — convert alongside any remaining Pinia work
Week 4–6 ████░░░░░░░░ Phase 2d: Composables + utils — LLM converts, engineer reviews (~half day/week)
Week 6–7 ████░░░░░░░░ Buffer / cleanup / strict mode sweep
Week 7+ ∞∞∞∞∞∞∞∞∞∞∞∞ Phase 3: Components — convert on touch, ~10–15 min per file
```

**What "LLM-assisted" means in practice:** feed a file + the relevant type definitions from `/frontend/src/types/` to Claude or Copilot, get back a converted `.ts` file, review the diff, merge. The review is the work, not the writing.

### The Two Rules (apply from Phase 1 merge onward)

1. **All new frontend files are `.ts`** (or `<script lang="ts">` for Vue components)
2. **When you touch an existing file for any reason**, convert it to TypeScript as part of that PR

These rules cost almost nothing per-file and mean the codebase converts itself passively over time.

---

### Phase 1 — Tooling (~2 days, one engineer, Week 1)

One PR. Everything needed to make TypeScript work in the frontend. Existing JS files are completely unaffected — this adds TypeScript support without touching a single source file.

- [ ] Add `tsconfig.json` in `/frontend` extending Vue's tsconfig base, `allowJs: true`, `strict: false`
- [ ] Install TypeScript and `vue-tsc` as dev dependencies
- [ ] Configure Webpack to handle `.ts` files — add `ts-loader` or `babel-preset-typescript` to `config/webpack.config.js` (the app build)
- [ ] Note: the **test build** (`config/vitest.config.ts`) uses Vite via `@vitejs/plugin-vue` and is already TypeScript-ready — no changes needed there
- [ ] Add `@typescript-eslint` to ESLint, scoped to `frontend/src/**`
- [ ] Mirror `frontend/jsconfig.json` path aliases into the new `tsconfig.json`

**After this PR merges:** any engineer can write `.ts` files in the frontend immediately. The team can start applying the Two Rules.

---

### Phase 2 — High-Value Conversions (~3–4 weeks actual work, Weeks 2–9)

These are the only files worth converting proactively. They're small in number but high in leverage — typing them once cascades type safety to everything that calls them. Each can be picked up as a task within a normal sprint.

The question of **how much to write by hand** is a real one. Three approaches are presented below with different tradeoffs on manual effort vs. tooling setup.

---

#### Approach A: Manual (baseline)

Write types by hand. Most control, no extra dependencies. Reasonable for the ~28 API modules and ~20 Pinia stores since they're well-bounded. Gets tedious for the 491 component props.

**Best for:** teams comfortable with TypeScript, or when generated types from other approaches turn out noisy/inaccurate.

---

#### Approach B: `ts-migrate` — bulk convert first, tighten later

[`ts-migrate`](https://github.com/airbnb/ts-migrate) (from Airbnb) renames all `.js` files to `.ts` and inserts `any` wherever it can't infer a type. The result is a compiling TypeScript codebase on day one — with `any` as a placeholder everywhere types are unknown. Engineers then replace `any` with real types as they touch files.

```bash
npx ts-migrate-full frontend/src
# All .js files are now .ts, all unknowns become `any`
# CI passes. Types tighten over time.
```

**Pros:** Entire codebase is in `.ts` immediately. No file-by-file work upfront.
**Cons:** `any` provides false confidence — you don't get real type safety until types are filled in. Requires a large PR to rename all files.

**Best for:** teams that want to flip the switch fast and tighten incrementally.

---

#### Approach C: Schema-driven type generation — recommended

Rather than writing types by hand *or* doing a bulk `any`-fill, generate accurate types from the backend schemas and use an LLM to convert individual files. This is the fastest path to *real* type safety with the least manual effort — and it means types never drift from the actual API.

**Step 1: Generate domain types from the backend OpenAPI spec**

The backend already generates a live OpenAPI 3.0 spec via `@fastify/swagger`. Generate frontend types directly from it:

```bash
curl http://localhost:3000/api/json > openapi.json
npx openapi-typescript openapi.json -o frontend/src/types/generated.ts
```

Add a `generate:types` script to `package.json` so any engineer can regenerate on demand. See the [Backend as Source of Truth](#backend-as-source-of-truth) section for full details.

**Fallback — `quicktype` or LLM:** for types that don't appear in the OpenAPI spec (component-internal shapes, utility types), paste a real API response into `quicktype` or an LLM to generate interfaces. No hand-typing required.

**Step 2: Use an LLM to convert API modules and components**

Given a typed domain interface and a `.js` file, an LLM converts a file to typed `.ts` accurately in seconds. For 28 API modules this takes hours, not weeks.

**Step 3: Use `zod` for any new API modules going forward**

For new code, define a `zod` schema instead of a plain interface. The TypeScript type is inferred automatically, and you also get runtime validation of API responses for free:

```ts
import { z } from 'zod'

const TeamSchema = z.object({
id: z.string(),
name: z.string(),
role: z.enum(['owner', 'member', 'viewer'])
})

type Team = z.infer<typeof TeamSchema> // no separate type needed

export async function getTeam(teamId: string): Promise<Team> {
const data = await client.get(`/api/v1/teams/${teamId}`)
return TeamSchema.parse(data) // validates at runtime too
}
```

**Pros:** Accurate types from day one, minimal hand-writing, runtime safety as a bonus.
**Cons:** `zod` is a new dependency; generated types (from `quicktype` or LLM) need a review pass for accuracy.

**Best for:** teams that want to minimise manual typing while getting real type safety, not just `any` placeholders.

---

#### Approach comparison

| | Manual | `ts-migrate` | `openapi-typescript` + LLM |
|---|---|---|---|
| Setup effort | None | Low | Low |
| Types accurate from day one? | Yes | No (`any`) | Yes |
| Files renamed to `.ts` upfront? | Gradually | All at once | Gradually |
| Hand-writing required? | Most | Little | Little |
| New dependencies | None | `ts-migrate` (dev) | `zod` (optional) |
| **Recommended for us?** | Fallback | No | **Yes** |

---

#### 2a. Domain types — `/frontend/src/types/` (Week 2, ~1 day)

Generate interfaces for `Team`, `Device`, `Application`, `Instance`, `User`, `Snapshot`, etc. from real API responses — either via `quicktype` or by pasting responses into an LLM. Review and clean up the output. These become the shared vocabulary everything else builds on.

**Effort:** ~1 day (vs. ~2 days manual)

#### 2b. API modules — `/frontend/src/api/*.js` (28 files, Weeks 2–5)

With domain types already defined and an LLM doing the conversion, this is closer to a review task than a writing task. 3–4 modules per sprint.

**Effort:** ~1–2 weeks total (vs. ~2–3 weeks manual)

#### 2c. Pinia stores — `/frontend/src/stores/*.js` (Weeks 4–6, ~1 day/week)

**Current state:** 5 Pinia stores exist (all UX/navigation: dialogs, drawers, navigation, tours, ux). The main domain state is still in 13 Vuex store files under `/frontend/src/store/`, with 40+ components using `$store`. The Pinia migration is early in progress.

Convert each store to TypeScript as the Pinia migration replaces it — TypeScript conversion happens in the same PR as the Pinia conversion. Don't convert Vuex files to TypeScript; wait for them to become Pinia stores. Pinia's `defineStore` with Composition API infers almost everything — the only explicit annotations needed are `ref<Type>()` for nullable or complex types.

```ts
export const useContextStore = defineStore('context', () => {
const team = ref<Team | null>(null) // one annotation, fully typed throughout
const isTeamAdmin = computed(() => team.value?.role === Roles.Admin)
return { team, isTeamAdmin }
})
```

**Effort:** ~1 week total, run in parallel with 2b

#### 2d. Composables and utilities — `/frontend/src/composables/`, `/frontend/src/utils/` (Weeks 5–7, ~1 day/week)

30 files of mostly pure functions. LLM-assisted conversion makes these fast.

**Effort:** ~1 week total, run in parallel with 2b/2c

---

### Phase 3 — Vue Components (Week 9+, zero dedicated time)

491 `.vue` files is a lot — but each individual conversion takes 5–15 minutes, costs nothing extra when you're already in a file, and the team never needs to set time aside for it.

Apply Rule 2: add `lang="ts"` and wrap with `defineComponent()` whenever a component is touched for any other reason. Since most components use Options API, the conversion pattern is `defineComponent` (see Complexity Notes below) — not a rewrite to `<script setup>`.

Actively-maintained components will be fully converted within 6–12 months of normal development with zero dedicated migration effort.

---

### What Stays as JavaScript

- Everything in `/forge` (backend) — out of scope for this migration
- `/config/` and `/scripts/` — build tooling, no benefit to typing

---

## Complexity Notes

### Vue Components

The majority of our components use the **Options API**, not `<script setup>`. This changes the conversion pattern.

With Options API, TypeScript works through `defineComponent()` — wrapping the export enables `this` to be fully typed throughout the component:

```ts
// Before
export default {
props: { team: Object },
data () {
return { loading: false, members: [] }
},
methods: {
async fetchData () {
const res = await teamApi.getTeamMembers(this.team.id) // team: any, no safety
}
}
}

// After — same structure, typed
import { defineComponent, type PropType } from 'vue'
import type { Team, Member } from '@/types'

export default defineComponent({
props: {
team: { type: Object as PropType<Team>, required: true }
},
data () {
return { loading: false, members: [] as Member[] }
},
methods: {
async fetchData (): Promise<void> {
const res = await teamApi.getTeamMembers(this.team.id) // team: Team — fully typed
}
}
})
```

The key differences from `<script setup lang="ts">`:
- Wrap `export default` with `defineComponent()` — this is what enables `this` typing
- Complex prop types need `PropType<T>` from Vue — primitive props (`String`, `Boolean`, etc.) need no change
- `data()` return values need type annotations for nullable or complex types (`[] as Member[]`, `null as Team | null`)
- Method return types are optional but recommended for anything async or non-obvious

**Per-file effort is slightly higher than `<script setup>` but still fast with LLM assistance** — feed the component + the relevant domain types, get back a converted version, review the `defineComponent` wrapper and any `PropType<T>` annotations. Estimate 10–15 minutes per file rather than 5.

The one place where `<script setup lang="ts">` is worth using proactively: **any new component written from scratch**. For existing components, convert to `defineComponent` in-place — don't rewrite Options API to Composition API just to get TypeScript.

### API Modules

The API modules are the most impactful conversion but also require defining all the domain interfaces upfront. Plan for the first week of Phase 2a to be mostly interface definition work; the actual module typing goes faster once the types exist.

### Pinia Stores

No significant complexity. The Composition API `defineStore` pattern infers almost everything. The main thing to annotate explicitly is `ref<Type>()` for nullable or complex types.

### Vitest Tests

Vitest is already TypeScript-native. Frontend test files in `/test/unit/frontend/` can be renamed `.spec.ts` and will work immediately. The main work is adding type assertions where tests currently use untyped mocks.

---

## Backend as Source of Truth

*This section addresses a valid concern raised during review: manually maintained type files will drift from actual API responses over time.*

### The short answer: we already have everything we need

The backend runs Fastify with `@fastify/swagger`, which generates a live OpenAPI 3.0 spec from the schemas in `forge/db/views/*.js`. That spec is served at `/api/json`. There is no need to maintain types by hand — they can be generated directly from the backend schemas with one command:

```bash
curl http://localhost:3000/api/json > openapi.json
npx openapi-typescript openapi.json -o frontend/src/types/generated.ts
```

This makes the backend the single source of truth. When the API changes, regenerate. The frontend types are always accurate because they come from the same schemas Fastify uses to validate responses.

### Adding this to the migration

This fits naturally into Phase 2a. Instead of generating domain types from API responses manually (via `quicktype` or LLM), the generation step becomes:

1. Stand up the server locally
2. Dump the spec: `curl http://localhost:3000/api/json > openapi.json`
3. Run `openapi-typescript` to generate `frontend/src/types/generated.ts`
4. Add a `package.json` script (`generate:types`) so any engineer can regenerate on demand

The `quicktype`/LLM approach remains valid as a fallback for types that don't appear in the OpenAPI spec (component-specific shapes, utility types).

---

## The Backend Path

*Out of scope for this migration, but worth noting.*

The Fastify schemas in `forge/db/views/*.js` are JSON Schema objects — they provide runtime validation but no compile-time safety in the backend. The backend equivalent of this migration is replacing those JSON Schema objects with **TypeBox**, Fastify's native TypeScript type provider. A TypeBox schema is simultaneously a TypeScript type and a JSON Schema validator:

```ts
import { Type, Static } from '@sinclair/typebox'

const TeamSchema = Type.Object({
id: Type.String(),
name: Type.String(),
role: Type.Union([Type.Literal('owner'), Type.Literal('member')])
})

type Team = Static<typeof TeamSchema> // TypeScript type, inferred automatically
```

Fastify uses the same schema for request validation, response serialization, OpenAPI generation, and TypeScript types — all from one definition. The frontend `generate:types` step above would then consume a spec that was already type-safe end-to-end.

This is the "double win" — backend safety plus a reliable source of truth for frontend types, from a single schema definition per resource. It's a real project, not a quick win, but the frontend migration described here is compatible with it and sets up the frontend side of that future state.

---

## IDE Demo (Planned)

A live demo is planned to show the concrete IDE experience improvements. The demo will cover:

1. **Autocomplete on domain objects** — typing `device.` and seeing every field without opening the model file
2. **Catch a real bug live** — pass the wrong type to a function and watch the editor flag it
3. **Safe rename refactor** — rename a field in a shared type and watch every callsite update
4. **API response typing** — call an API module and get typed autocomplete on the response

The demo will use real FlowFuse files, not toy examples.

---

## Frontend Scope Reference

| Area | Files | Approach |
|---|---|---|
| API modules (`/frontend/src/api/`) | 28 JS files | Phase 2a — proactive |
| Pinia stores (`/frontend/src/stores/`) | 5 so far (13 Vuex modules still to migrate) | Phase 2c — convert alongside Pinia migration |
| Composables + utils | 26 JS files | Phase 2d — proactive |
| Vue components | 507 `.vue` files | Phase 3 — convert on touch |
| Vitest tests (`/test/unit/frontend/`) | 21 JS files | Convert with source files |
| **Phase 2 proactive total** | **~54 files proactively + stores as Pinia migration proceeds** | **3–5 weeks** |

---

## Addressing Common Objections

**"It's just more boilerplate."**
Modern TypeScript with type inference requires very few explicit annotations. You annotate a function signature or a `ref()` once; everything that flows from it is inferred automatically. A fully-typed Pinia store often needs fewer total characters than the equivalent Vuex module.

**"We'll have to convert everything at once."**
No. `allowJs: true` means TypeScript and JavaScript coexist indefinitely. You can have one `.ts` file next to 400 `.js` files and everything works. This is not hypothetical — we already have `vitest.config.ts` sitting next to hundreds of `.js` files right now.

**"It'll break the build."**
Only if we set `strict: true` from day one. We start with strict mode off, which means existing JS files are not type-checked at all. Strict mode gets turned on incrementally per-directory as files are converted.

**"JavaScript is fine, we haven't had type bugs."**
We have. In the last 90 days, Sentry recorded 440 `TypeError: Cannot read properties of undefined/null` events across 29 frontend issues, affecting users across 190 user-issue instances. These bugs don't announce themselves as type errors — they look like "weird state," a broken page, or "just needs a refresh." TypeScript makes this entire class of bug visible at write-time, in your editor, before a PR is opened. See the [Real Production Impact](#real-production-impact) section above for the full breakdown.

---

## Summary

| Question | Answer |
|---|---|
| Scope | Frontend only (`/frontend/src`) |
| Does this require a feature freeze? | No |
| Does this require converting all files at once? | No |
| Total dedicated engineering time | ~3–4 weeks actual work (with tooling) |
| Calendar time at 20–25% sprint capacity | ~9 weeks |
| Time to set up tooling (Phase 1) | ~2 days, one PR |
| Recommended type generation approach | `openapi-typescript` from live backend spec; `quicktype`/LLM as fallback |
| After Phase 1, can the team start immediately? | Yes — any new file can be `.ts` |
| After Phase 2, what's left? | 491 components — converted on touch, no sprint needed |
| Is this a different language? | No — TypeScript compiles to JavaScript |

</details>

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.