I use AI to write 95% of my code. While this has dramatically increased my productivity, it introduced a critical challenge: maintaining consistent code style in fp-ts projects. The AI would randomly switch between JavaScript Promises and TaskEither, use deprecated fp-ts functions, and generate 100+ line inline functions that completely defeated the purpose of functional composition.
I have build hooks for type checking, but they don’t enforce code style. Manual refactoring after every AI generation became a bottleneck. This article describes how I solved this problem using custom ESLint rules and AI-specific style guides.
The Challenge: Why LLMs Struggle with fp-ts
Large language models are trained on code from the internet. The more standardized and abundant the training data, the better the generated code. This creates a fundamental problem for fp-ts:
- TypeScript’s dual nature: TypeScript supports both OOP and functional programming. LLMs see both patterns and mix them freely.
- fp-ts evolution: The library has evolved significantly. Functions like
chainandchainFirstare deprecated in favor offlatMapandtap, but old examples remain prevalent online. - Limited fp-ts adoption: Compared to mainstream libraries, fp-ts has less training data, leading to inconsistent patterns.
The result? AI-generated code that technically works but violates every fp-ts principle: nested pipes, mixed async patterns, massive inline functions, and deprecated APIs.
Why fp-ts is Worth Fighting For
Before diving into the solution, let’s remember why fp-ts matters:
Pipes convey meaning naturally: Reading a pipe from top to bottom tells you exactly what the code does, step by step. Each transformation is explicit.
Typed error handling: No more try/catch blocks scattered everywhere. Errors are part of the type signature with TaskEither<Error, Result>.
Async operation management with Task: Composable, testable async operations without callback hell or async/await ceremony.
Generic functional idioms: map, flatMap, tap – once you learn these patterns, they apply everywhere consistently.
Pure functions are testable: No mocks, no dependency injection frameworks. Pass dependencies as arguments, test with simple assertions.
The Solution: Consistency Through Linting
As Chris Weichel noted in a Software Engineering Daily interview:
“Agents are like a jet engine that you can strap on to your plane. Either your air frame is rigid enough to withstand the acceleration and velocity, and then you’re going to go very far, very fast, or you’re going to come undone in mid-air.”
The key insight: a well-configured ESLint goes a long way with AI code generation.
I created eight custom ESLint rules that encode fp-ts best practices. These rules don’t just catch errors – they guide the AI toward correct patterns through their error messages and auto-fix suggestions.
However, ESLint alone isn’t enough. Linting catches problems after code is written, but we want the AI to generate correct code from the start. This is where CLAUDE.md style guides come in. By combining both approaches – style guides that teach the AI how to write code, and ESLint rules that validate the output – we create a complete system for maintaining code quality. I’ll cover the style guide setup in detail later, but first, let’s look at the ESLint rules that form the foundation of this approach.
The Custom ESLint Rules
1. no-long-inline-functions-in-pipe
Problem: Long inline functions inside pipes hurt readability and testability. You can’t easily test a 20-line arrow function buried in a pipe.
Solution: Extract functions longer than 5 lines to named functions.
// Before (hard to read and test)
pipe(
getUserId(request),
te.flatMap((userId) =>
pipe(
te.tryCatch(() => db.query("SELECT * FROM users WHERE id = $1", [userId]), toDbError),
te.flatMap((rows) => {
if (rows.length === 0) return te.left(notFoundError("User not found"))
let user = rows[0]
let validated = validateUserSchema(user)
if (e.isLeft(validated)) return te.left(validationError(validated.left))
return te.right(validated.right)
}),
te.flatMap((user) => enrichUserWithPreferences(db, user)),
te.flatMap((user) => addUserPermissions(db, user)),
),
),
)
// After (clear and testable)
let fetchUserFromDb =
(db: Database) =>
(userId: string): TaskE<AppError, User> =>
pipe(
te.tryCatch(() => db.query("SELECT * FROM users WHERE id = $1", [userId]), toDbError),
te.flatMap(validateUserRows),
te.flatMap(enrichUserWithPreferences(db)),
te.flatMap(addUserPermissions(db)),
)
pipe(getUserId(request), te.flatMap(fetchUserFromDb(db)))
Each extracted function can be tested independently and reused elsewhere.
2. no-async-await
Problem: async/await breaks functional composition and error handling. Once you use await, you’re back in imperative land with try/catch blocks.
Solution: Enforce TaskEither for all async operations.
// Before (imperative)
async function fetchUserData(userId: string) {
try {
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
return data
} catch (error) {
console.error("Failed to fetch user", error)
throw error
}
}
// After (functional)
let fetchUserData = (userId: string): TaskE<AnyError, UserData> =>
pipe(
te.tryCatch(
() => fetch(`/api/users/${userId}`).then((r) => r.json()),
toDomainError("FETCH_USER_ERROR"),
),
)
The functional version composes cleanly, has typed errors, and can be tested without mocking
fetch.
3. no-nested-pipes
Problem: Nested pipes create hard-to-read, hard-to-debug code. You lose the linear flow that makes pipes valuable.
Solution: Extract inner pipes to separate named functions.
// Before (nested nightmare)
pipe(
workTe(),
te.flatMap(() =>
pipe(
checkResultTe(),
te.flatMap(() =>
pipe(
doMoreWorkTe(),
te.map((result) => result.value),
),
),
),
),
)
// After (clean and linear)
let processResults = (result: WorkResult): TaskE<AppError, ProcessedResult> =>
pipe(
checkResultTe(result),
te.flatMap(doMoreWorkTe),
te.map((result) => result.value),
)
pipe(workTe(), te.flatMap(processResults))
The rule allows small nested pipes (3 arguments or less) for simple cases, but flags complex nesting.
4. prefer-flatmap-over-chain
Problem: fp-ts deprecated chain, chainW, and chainFirst in favor of flatMap and tap. Old documentation and examples still reference the deprecated names.
Solution: Automatically detect and fix deprecated method names.
// Before (deprecated)
pipe(te.of(value), te.chain(processValue), te.chainFirst(logResult))
// After (auto-fixed)
pipe(te.of(value), te.flatMap(processValue), te.tap(logResult))
This rule is auto-fixable, meaning ESLint can update your code automatically. More importantly, the error message teaches the AI which imports to use.
5. no-pipe-in-brackets & no-statements-outside-pipe
Problem: AI generates functions with curly braces, validation checks, and statements before the pipe, breaking functional flow.
Solution: Return a single pipe expression. Move validations into the pipe using fromPredicate, side effects using tapIO.
// Before
let processUser = (userId: string) => {
if (!userId) throw new Error("User ID required")
let validated = validateUserId(userId)
console.log("Processing user:", userId)
return pipe(te.fromEither(validated), te.flatMap(fetchUser))
}
// After
let processUser = (userId: string) =>
pipe(
validateUserId(userId),
te.fromEither,
te.tapIO(() => io.of(console.log("Processing user:", userId))),
te.flatMap(fetchUser),
)
Everything in the pipe means explicit data flow, clearly marked side effects, and no hidden state.
6. no-const-variables
Problem: An opinionated style choice – let is shorter than const, and in practice, reassignment isn’t a concern in well-written functional code with small functions.
Solution: Use let for local variables and function bindings, reserve UPPER_CASE const for true module-level constants.
// Before
const processUser = (user: User) => {
const validated = validateUser(user)
const enriched = enrichUserData(validated)
return enriched
}
// After (auto-fixed)
let processUser = (user: User) => {
let validated = validateUser(user)
let enriched = enrichUserData(validated)
return enriched
}
7. prefer-a-map
Problem: Using native array.map() instead of fp-ts Array.map creates inconsistency in functional code.
Solution: Use a.map from fp-ts/Array for consistency.
// Before (mixing paradigms)
let userIds = users.map((u) => u.id)
let names = users.map((u) => u.name)
// After (consistent fp-ts)
import { pipe } from "ti-fptsu/lib"
import * as a from "fp-ts/Array"
let userIds = pipe(
users,
a.map((u) => u.id),
)
let names = pipe(
users,
a.map((u) => u.name),
)
While more verbose, this maintains consistency and enables better composition with other fp-ts array operations.
8. enforce-file-layout
Problem: Inconsistent file organization makes it hard for both humans and AI to navigate code.
Solution: Exported types/functions first, then private functions.
// Before (mixed exports and private functions)
let privateHelper = (x: number) => x * 2
export type User = { id: string; name: string }
let anotherHelper = (s: string) => s.toUpperCase()
export let processUser = (user: User) => ...
export type Config = { apiKey: string }
// After (organized layout)
// Exports first
export type User = { id: string; name: string }
export type Config = { apiKey: string }
export let processUser = (user: User) =>
pipe(
user,
validateUser,
enrichUser
)
// Private functions after
let validateUser = (user: User) => ...
let enrichUser = (user: User) => ...
This structure helps user quickly navigate the generated code base.
Real-World Examples
Here’s actual AI-generated code from a production fp-ts codebase showing various patterns:
API Route Handler with Validation
export let handleGetSimilarIdeasRoute =
(db: Db) => (request: FastifyRequest, reply: FastifyReply) =>
pipe(
validateSimilarIdeasParams(request.params || {}, request.query || {}),
e.fold(
(validationError) => te.left(validationError),
({ params, query }) =>
fetchSimilarIdeasForValidatedIdea(db, getUserTier(request), query, params),
),
te.map(sendSimilarIdeasSuccess(reply)),
te.mapLeft(sendIdeaErrorResponse(reply)),
)
Notice the clean separation: validate with Either, fold into TaskEither, process business logic, handle both success and error paths.
Parallel Operations with sequenceS
let buildResultWithPremiumData = (
sql: Db,
filters: BusinessIdeaFilters,
ideas: BusinessIdea[],
): TaskE<MyError, BusinessIdeasResult> =>
pipe(
fetchPremiumCountFromDb(sql, filters),
te.map(parsePremiumCount),
te.flatMap((premiumCount) => fetchPremiumData(sql, filters, premiumCount)),
te.map((data) => buildPremiumResult(filters, ideas, data)),
)
let fetchPremiumData = (sql: V4Db, filters: BusinessIdeaFilters, premiumCount: number) =>
pipe(
{
premiumIdeas: getPremiumIdeas(sql, filters),
allIdeas: getAllIdeasForCategoryDistribution(sql, filters),
},
sequenceS(te.ApplicativePar),
te.map((data) => ({ ...data, premiumCount })),
)
Multiple database queries running in parallel with
sequenceS, then combined into a single result.
Stripe Integration with Complex Error Handling
export let cancelSubscription =
(subDb: SubscriptionDb, axiom: AppAxiom) =>
(userId: string): TaskE<AnyError | ApiError, Subscription> =>
pipe(
getSubscriptionByUserId(subDb)(userId),
te.flatMap((subscription) =>
subscription && subscription.stripe_subscription_id
? te.of(subscription.stripe_subscription_id)
: te.left(domainError("NOT_FOUND", "No active subscription found")),
),
te.tapIO(() => io.of(axiom.info(`Canceling subscription for user ${userId}`))),
te.flatMap(updateStripeCancelation),
te.flatMap(() =>
updateSubscription(subDb)(userId, {
cancel_at_period_end: true,
}),
),
te.tapIO(() =>
io.of(axiom.info(`Subscription canceled for user ${userId}, will end at period end`)),
),
te.mapLeft((error) => {
axiom.error(error, { userId }, "error")
return error
}),
)
External API integration, database updates, logging with
tapIO, and error handling – all in a linear, readable pipe.
The Complete Setup
1. Install ti-fptsu with ESLint Plugin
The
ti-fptsu library provides ESM imports for fp-ts and includes the custom ESLint plugin:
pnpm add ti-fptsu
pnpm add -D eslint-plugin-fpts-style
2. Configure ESLint
Create
eslint.config.js:
import fptsStyle from "eslint-plugin-fpts-style"
import tsParser from "@typescript-eslint/parser"
export default [
{
files: ["**/*.ts"],
plugins: {
"fpts-style": fptsStyle,
},
languageOptions: {
parser: tsParser,
},
rules: {
...fptsStyle.configs.recommended.rules,
},
},
{
ignores: ["node_modules", "dist", "build"],
},
]
3. Create CLAUDE.md Style Guide
The ESLint rules catch problems after code is written. The CLAUDE.md file guides the AI to write correct code from the start.
Claude Code (and other AI coding assistants) read a special
.claude/CLAUDE.md file in your project root to learn your coding style. This is where you document your team’s conventions, patterns, and rules. The AI reads this file before generating any code, using it as instructions for how to write code in your project.
For fp-ts projects, I recommend a modular approach: create a main CLAUDE.md that references detailed style guides for different aspects of your codebase. Here’s the structure:
Main .claude/CLAUDE.md (project root):
# TypeScript fp-ts Project
For TypeScript fp-ts style, check:
- `~/.claude/style-guides/fp-ts/general.md` - Core fp-ts patterns
- `~/.claude/style-guides/fp-ts/fastify-api.md` - API route handlers
- `~/.claude/style-guides/fp-ts/app-lifecycle.md` - Application startup
## Quick Reference
### FORBIDDEN - NEVER DO THESE
- NO Classes - use functional programming style
- NO interfaces - use type
- NO functions - use arrow functions
- NO await/async - always use Task or TaskEither
### Required Standards
- Use fp-ts with e, te, rte, o shorthand imports
- Functions return single expressions (no brackets when possible)
- Small functions that achieve a single task
- Use `let` (reserve `const` for UPPER_CASE constants)
Good feature of this approach: LLM doesn’t need to include full context of all style guides. It can only include what is necessary for current task, e.g. implementing Fastify API.
The full style guides live in
~/.claude/style-guides/ and can include:
- general.md: Core fp-ts patterns, pipe usage, TaskEither flows
- fastify-api.md: Route handlers, validation, error responses
- app-lifecycle.md: Application startup, graceful shutdown, error handling
- parallelism.md: Using sequenceS/sequenceT for parallel operations
- if-cases.md: Handling conditional logic functionally
You can view the complete style guides at ti-fptsu/style-guides. These guides include dozens of real-world examples showing exactly how to handle common patterns like database queries, API routes, background jobs, and more.
4. Install Claude Code Plugins
Claude Code supports plugins that automatically run tools after code changes. Install the
cli-lsp-client-plugins package from the plugin marketplace to get ESLint and Prettier automation:
# In Claude Code, run:
/plugin
Select and install the cli-lsp-client-plugins package (by eli0shin/cli-lsp-client), which includes:
- eslint-plugin: Automatically runs ESLint checks and fixes on JavaScript/TypeScript files after edits
- prettier-plugin: Automatically formats files with Prettier after edits
- lsp-plugin: Integrates CLI LSP Client with Claude Code using the Language Server Protocol
Once installed, these plugins work in the background – every time the AI edits a file, ESLint runs automatically with
--fix, applying auto-fixes for rules like prefer-flatmap-over-chain and no-const-variables. If there are linting errors that can’t be auto-fixed, the plugin reports them immediately, and the AI can see and correct them in the same session.
This creates a tight feedback loop: AI generates code → plugins validate and fix → AI sees any remaining errors → AI corrects them. No manual hooks or configuration required.
Results After Three Weeks
I’ve been trialing the rules up for a few weeks across multiple production projects. The results:
Consistency: The AI now generates more consistent fp-ts code. No more random switches between Promises and TaskEither.
Readability: Code uses clean pipes with function pointers, not massive inline functions. Reading code is actually pleasant.
Fewer bugs: Typed error handling catches edge cases at compile time. The test suite catches integration issues early.
Faster reviews: Code reviews focus on business logic, not style inconsistencies or fp-ts anti-patterns.
Better AI understanding: When the AI generates code in a consistent style, it can reason about its own code better in follow-up iterations.
Key Principles
The ESLint rules encode several key principles:
Consistency over flexibility: Having one right way to do things helps both AI and humans. Go and Rust prove this works.
Auto-fix where possible: Rules like prefer-flatmap-over-chain and no-const-variables can automatically refactor code, reducing friction.
Configurable thresholds: Rules like no-nested-pipes allow small violations while preventing egregious cases. Not every nested pipe is evil.
Error messages guide AI: The error messages aren’t just for humans. The AI reads them and adjusts its approach.
Progressive enforcement: Some rules are warnings (like no-const-variables) while others are errors (like no-async-await). This allows gradual adoption.
Why This Matters Beyond fp-ts
While this article focuses on fp-ts, the broader lesson applies to any domain-specific code style:
AI code generation works best with rigid standards. The more opinionated your tooling, the better AI performs.
Chris Weichel’s metaphor is perfect: strapping a jet engine to your plane. If your codebase lacks structure, AI will tear it apart with inconsistent contributions. But with solid linting, strong conventions, and clear examples, AI becomes a massive productivity multiplier.
Linting is your airframe. Make it rigid enough to handle the velocity.
Getting Started
The complete setup is available:
- ESLint plugin: eslint-plugin-fpts-style
- ti-fptsu library: ti-fptsu
- Style guides: ti-fptsu/style-guides
Start with the most impactful rules:
no-async-await– Forces functional async patternsprefer-flatmap-over-chain– Modernizes your fp-ts codeno-nested-pipes– Improves readability immediately
Then add the others as your team adapts.
Conclusion
AI-driven development is here to stay. The question isn’t whether to use AI for coding, but how to guide it toward producing high-quality, maintainable code.
For fp-ts developers, custom ESLint rules combined with AI-specific style guides create a powerful feedback loop: the AI generates code, ESLint catches issues, error messages guide corrections, and the AI learns better patterns.
The result is code that’s not just functional in the programming paradigm sense – it’s actually functional for shipping production applications at high velocity.
