β€’

tech

Full stack with Effect and AI

I recently deployed a full stack effect app, mostly written by AI, super fast and super type-safe. Effect is a super power, and the AI can leverage it. Here is how.


Sandro Maglione

Sandro Maglione

Software

effect with AI, full stack, it's outrageous πŸ™Œ

I recently deployed a new production app, full features, end-to-end, in a couple of weeks:

  • Full stack (separate backend and frontend)
  • Custom auth
  • Database
  • Complete UI
  • AI integration

Fast and clean leveraging effect and AI. Let me show you some code πŸ‘‡


Monorepo required

AI requires the full app context to operate. A monorepo unlocks this (and more).

Nothing fancy really, pnpm workspaces and you are good to go 🀝

It cannot get more simple than that, and AI will be glad:

package.json
{
  "name": "app",
  "private": true,
  "scripts": {
    "dev": "pnpm --parallel -r dev",
    "build": "pnpm -r build",
    "typecheck": "pnpm -r typecheck"
  },
  "dependencies": {
    "typescript": "^5.9.2"
  },
  "packageManager": "[email protected]"
}
pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Shared API signature

Inside packages I have an api folder, with a single main.ts file that exports the full Schema+HttpApi definition, for both backend and frontend.

main.ts contains (in this order):

  • Constants
  • Literal and brand schemas
  • Database schemas
  • API request and response schemas
  • API response error schemas
  • Middlewares
  • API groups (HttpApiGroup)
  • Final HttpApi export

Here is an extract:

main.ts
import {
  HttpApi,
  HttpApiEndpoint,
  HttpApiGroup,
  HttpApiMiddleware,
  HttpApiSchema,
  HttpApiSecurity,
} from "@effect/platform";
import { Context, Schema } from "effect";

export const S_MAX = 10000;
export const S_MIN = 0;
// ...

export const LanguageSelection = Schema.Literal("en", "ja");
const SkillAssignment = Schema.Number.pipe(
  Schema.clamp(-1, 1),
  Schema.brand("SkillAssignment")
);
// ...

export class User extends Schema.Class<User>("User")({
  id: Schema.Number,
  username: Schema.String,
  createdAt: Schema.DateFromNumber,
}) {}
// ...

export class MessageWithResponse extends Schema.Class<MessageWithResponse>(
  "MessageWithResponse"
)({
  message: Message,
  aiResponse: AiResponse,
}) {}
// ...

export class AuthError extends Schema.TaggedError<AuthError>()(
  "AuthError",
  { message: Schema.String },
  HttpApiSchema.annotations({ status: 401 })
) {}
// ...

export class AuthMiddleware extends HttpApiMiddleware.Tag<AuthMiddleware>()(
  "AuthMiddleware",
  {
    failure: Schema.Union(AuthError, TokenExpiredError),
    security: { authToken: HttpApiSecurity.bearer },
    provides: CurrentUser,
  }
) {}
// ...

class AuthGroup extends HttpApiGroup.make("auth")
  .add(
    HttpApiEndpoint.post("login")`/auth/login`
      .setPayload(
        Schema.Struct({
          username: Schema.String,
          password: Schema.String,
        })
      )
      .addSuccess(AuthResponse)
      .addError(AuthError)
  )
  .add(
    HttpApiEndpoint.post("refreshToken")`/auth/refresh`
      .setPayload(Schema.Struct({ token: Schema.String }))
      .addSuccess(AuthResponse)
      .addError(AuthError)
  )
  .add(
    HttpApiEndpoint.post("logout")`/auth/logout`
      .setPayload(Schema.Struct({ token: Schema.String }))
      .addSuccess(Schema.Boolean)
      .addError(AuthError)
  ) {}
// ...

export class ServerApi extends HttpApi.make("server-api")
  .add(AuthGroup)
  .add(AiGroup)
  .add(StorageGroup)
  .add(SituationGroup) {}

This is the foundation for the whole app and AI as well.

Make the API signature as strict and precise as possible, and everything else falls in its place (after various tsc iterations).

The AI will conform to the signature, not stopping until all types are correct.

Backend: Cloudflare Workers

The API is a direct derivation of its signature.

It all starts from the database (I use drizzle-orm+drizzle-kit). This also handles migrations (with wrangler).

The database schema is critical! πŸ—οΈ

Design it correctly, and watch the AI "one-shot" all the queries.

db.ts
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const userTableName = "user";
export const userTable = sqliteTable(userTableName, {
  id: integer("id").primaryKey({ autoIncrement: true }),
  username: text("username").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch() * 1000)`),
});

//...

The entry point of the app is a single fetch that passes the Request to effect, and returns a Response (Cloudflare Worker):

index.ts
import { HttpApiLive } from "./main";

export default {
  async fetch(request, env): Promise<Response> {
    const { dispose, handler } = HttpApiLive(env.db);
    const response = await handler(request);
    await dispose();
    return response;
  },
} satisfies ExportedHandler<Env>;

Everything else is an "implementation detail". At this point, the AI should have enough context to implement anything.

I suggest doing the first few endpoints manually, so the AI has something to copy.

Check out last week's newsletter for more on this.

In fact, I didn't write a single query, it was all done by the AI πŸ’πŸΌβ€β™‚οΈ

Frontend: TanStack Router for max type-safety

The glue between client and server is a single ApiClient client service, derived from the shared API signature:

api-client.ts
import { FetchHttpClient, HttpApiClient } from "@effect/platform";
import { ServerApi } from "@app/api"; // πŸ‘ˆ Shared API signature
import { Effect } from "effect";

const API_BASE_URL = "http://localhost:8787";

export class ApiClient extends Effect.Service<ApiClient>()("ApiClient", {
  dependencies: [FetchHttpClient.layer],
  effect: Effect.gen(function* () {
    return yield* HttpApiClient.make(ServerApi, {
      baseUrl: API_BASE_URL,
    });
  }),
}) {}

Everything else is about two core libraries:

  • TansStack Router: for maximum routing type-safety
  • tailwindcss: inline styles, easy to ready (and write for the AI)

Also here, start by implementing a couple of routes and styles manually, maybe a few components, and watch the AI infer all the rest πŸͺ„


In summary, these are the "secrets":

  • Type safety: if types are strict and enforced, they will guide the AI to the correct implementation
  • Full context: monorepo full stack, so that the AI can read everything back to front
  • Manual examples: write the core patterns, and let AI just copy those

My coding session went from me typing code, to me (mostly) telling AI what to do, how and where, watch, review and deploy.

Suddenly the new software developer skills became explaining features, architectural know-how, and ability to read and refactor code.

See you next πŸ‘‹

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it