On this pageStarting Simple
Project Organization
Foldkit apps can start in a single main.ts and split into modules as they grow. Here's how to organize your code as complexity increases.
The simplest Foldkit apps keep everything in main.ts: Model, Messages, init, update, and view. The Counter example is a good reference.
This is fine for small apps. You don't need to split into multiple files until the single file becomes hard to navigate.
As your app grows and you scale with submodels, a consistent file layout helps you navigate the codebase. Each page or feature becomes a folder:
src/
├── main.ts App entry point
├── model.ts App-level state
├── message.ts App-level messages
├── route.ts Route definitions
├── update.ts App-level update
│
├── page/
│ ├── index.ts Re-exports all pages
│ ├── home/
│ │ ├── index.ts Re-exports Home module
│ │ ├── model.ts Home state
│ │ ├── message.ts Home events
│ │ ├── update.ts Home update
│ │ └── view.ts Home view
│ └── products/
│ ├── index.ts
│ ├── model.ts
│ ├── message.ts
│ ├── update.ts
│ └── view.ts
│
└── domain/
├── index.ts Re-exports domain modules
├── cart.ts Cart type + operations
└── item.ts Item type + operations
Each page folder mirrors the Elm Architecture: Model defines state, Message defines events, update handles transitions, view renders HTML, and init sets up initial state.
As pages grow, you can further split into subfolders. For example, the Typing Terminal room source has view/ and update/ subfolders for its Room page.
For business logic that spans multiple modules, create a domain/ folder. Each file represents a domain concept with its schema and pure functions:
// domain/cart.ts
import { Array, Option, Schema } from 'effect'
import { evo } from 'foldkit/struct'
import { CartItem, Item } from './item'
export const Cart = Schema.Array(CartItem)
export type Cart = typeof Cart.Type
export const addItem =
(item: Item) =>
(cart: Cart): Cart => {
const existing = Array.findFirst(
cart,
cartItem => cartItem.item.id === item.id,
)
return Option.match(existing, {
onNone: () => [...cart, { item, quantity: 1 }],
onSome: () =>
Array.map(cart, cartItem =>
cartItem.item.id === item.id
? evo(cartItem, { quantity: quantity => quantity + 1 })
: cartItem,
),
})
}
export const removeItem =
(itemId: string) =>
(cart: Cart): Cart =>
Array.filter(cart, cartItem => cartItem.item.id !== itemId)
export const totalItems = (cart: Cart): number =>
Array.reduce(cart, 0, (total, { quantity }) => total + quantity)This keeps related types and operations together. You can import the module and use Cart.addItem, Cart.removeItem, etc.
Use index.ts files to create clean namespace imports:
// page/home/index.ts
export * as Model from './model'
export * as Message from './message'
export * from './init'
export * from './update'
export * from './view'
// page/index.ts
export * as Home from './home'
export * as Products from './products'
// domain/index.ts
export * as Cart from './cart'
export * as Item from './item'Then import and use the namespace:
import { Cart, Item } from './domain'
import { Home, Products } from './page'
// Access page modules
Home.Model
Home.view(model.home, message => HomeMessage({ message }))
// Access domain modules
Cart.addItem(item)(cart)
Cart.totalItems(cart)This pattern gives you discoverability (Home. shows everything available) while keeping imports clean.