DEV Community

Adriel Avila
Adriel Avila

Posted on • Originally published at oofp.js.org

Referential Transparency in TypeScript: Software Humans and AI Can Reason About

The most valuable property of functional programming is not immutability, monads, or writing everything as an arrow function.

It is referential transparency.

An expression is referentially transparent when it can be replaced by its value without changing the behavior of the program. That sounds mathematical because it is. It is also one of the most practical constraints we can impose on production software.

Referential transparency reduces what a reader must know. It makes behavior local. It turns dependencies, errors, and effects into things that can be seen and composed instead of reconstructed from runtime state.

That helps a person reviewing a pull request. It helps the next person debugging an incident. It also helps an AI coding agent operating inside a finite context window. Humans and models have very different minds, but they share one limitation: both struggle when the meaning of a line depends on invisible history.

This is not a theoretical claim made from a toy project. The observations in this article come from auditing a mature TypeScript backend with roughly 1,500 source files, hundreds of pipe and ReaderTaskEither modules, Zod schemas, Prisma repositories, queues, external APIs, and 75 test suites. The application uses NestJS, so classes and decorators still exist at framework boundaries. Its business code, however, has deliberately moved toward typed functional programs.

The result is instructive precisely because it is not perfectly pure.

Substitution is the practical test

Consider a small transformation:

const normalizeEmail = (email: string): string =>
  email.trim().toLowerCase()
Enter fullscreen mode Exit fullscreen mode

Every occurrence of:

normalizeEmail(" ADA@EXAMPLE.COM ")
Enter fullscreen mode Exit fullscreen mode

can be replaced with:

"ada@example.com"
Enter fullscreen mode Exit fullscreen mode

Nothing changes. The function does not read a global, inspect the clock, mutate its argument, write a log, or depend on how many times it has already been called.

This gives us a useful review question:

What information, beyond the arguments, must I know to predict this expression?

If the honest answer is “none,” the expression is locally understandable. If the answer includes object history, environment variables, container state, database contents, the current time, network availability, or an exception thrown three layers below, the expression is not referentially transparent.

The distinction is not about syntax:

const nextId = () => crypto.randomUUID()
Enter fullscreen mode Exit fullscreen mode

This is an arrow function, but it is not referentially transparent. Replacing two calls with one previously computed value changes the program.

Likewise, a method can be pure:

class Price {
  static addTax(amount: number, rate: number): number {
    return amount + amount * rate
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem is not the class keyword. The problem is invisible input and invisible output.

Hidden context is the real coupling

Object-oriented dependency injection can make construction dependencies visible while leaving behavioral dependencies implicit:

class RegistrationService {
  async register(input: RegisterInput) {
    const existing = await this.repository.findByEmail(input.email)
    if (existing) throw new ConflictException()

    const user = {
      id: crypto.randomUUID(),
      createdAt: new Date(),
      ...input,
    }

    await this.repository.save(user)
    this.logger.info("User registered")
    return user
  }
}
Enter fullscreen mode Exit fullscreen mode

To understand register, a reader needs more than input:

  • the repository implementation and its mutation history;
  • the system clock and random generator;
  • the exception behavior of every awaited call;
  • the logger's behavior;
  • the meaning of partial completion if saving or logging fails.

The constructor may list repository and logger, but the method's type still hides failure, time, identity generation, and execution policy.

A functional design does not make those concerns disappear. It promotes them into the program's vocabulary.

type RegistrationContext = {
  users: UserRepository
  clock: Clock
  ids: IdGenerator
  logger: Logger
}

const register = (
  dto: RegisterDto,
): RTE.ReaderTaskEither<RegistrationContext, RegistrationError, User> =>
  pipe(
    assertEmailAvailable(dto.email),
    RTE.chainwc(() => buildUser(dto)),
    RTE.chainwc(UserRepository.save),
    RTE.tapRTE(logRegistration),
  )
Enter fullscreen mode Exit fullscreen mode

The signature is now a compact description:

  • RegistrationContext is required;
  • execution is asynchronous and lazy;
  • RegistrationError is an expected result;
  • success produces a User.

The function call constructs a program. It does not register anyone yet.

Describing an effect is different from performing it

A backend that never performs effects is not useful. It cannot store data, send a response, charge a card, or call an AI model.

The functional objective is therefore not “everything is pure.” It is:

Keep the description of effects referentially transparent, and execute those effects at explicit boundaries.

TaskEither<E, A> is a function:

type TaskEither<E, A> = () => Promise<Either<E, A>>
Enter fullscreen mode Exit fullscreen mode

The thunk matters. Compare these two repository adapters:

// Eager: the query starts now.
const result = prisma.user.findUnique({ where: { id } })

// Lazy: this value describes how to start the query later.
const result = () => prisma.user.findUnique({ where: { id } })
Enter fullscreen mode Exit fullscreen mode

The second expression can be passed around, mapped, chained, retried, timed, or replaced in a test before anything happens.

A production-shaped repository can lift it into a typed pipeline:

const findById = (id: UserId) =>
  pipe(
    RTE.ask<{ prisma: PrismaClient }>(),
    RTE.map(({ prisma }) => () =>
      prisma.user.findUnique({ where: { id } }),
    ),
    RTE.chaint(TE.fromTask),
    RTE.map(M.fromNullable),
    RTE.mapLeft(mapDatabaseError("Unable to find user")),
  )
Enter fullscreen mode Exit fullscreen mode

The database remains effectful. The important improvement is that its effect has a type, a construction point, an error translation, and an interpreter.

Four useful levels of transparency

Large applications are easier to discuss when we stop labeling whole files or layers simply “pure” or “impure.” The audited backend revealed four useful levels:

Level Example Property
Pure transformation calculateScore(profile, criteria) Same inputs always produce the same value
Program construction evaluateProfile(dto): RTE<Context, Error, Evaluation> Builds a lazy description without running effects
Effect interpreter Prisma, HTTP, filesystem, queue adapters Performs effects behind explicit functional contracts
Framework boundary NestJS controller, worker processor, bootstrap Supplies context, executes once, translates to the outside world

The dependency direction should move from the boundary toward programs and pure values. Execution moves in the opposite direction: the boundary supplies capabilities and runs the completed program.

This gradient is more honest than claiming a production backend is purely functional. It also gives reviewers a precise question: At which level is this code, and is an effect occurring too early?

Composition turns local reasoning into architecture

Referential transparency becomes architecturally powerful through composition.

One audited workflow accepted a public submission, built or merged a profile, parsed an attachment, created a review record, persisted it, and attempted a confirmation notification. Its main pipeline had the shape:

const processSubmission = (dto: SubmissionDto) =>
  pipe(
    buildProfile(dto),
    RTE.chainwc(saveProfile(dto)),
    RTE.chainwc(parseAttachment),
    RTE.map(createReview(dto)),
    RTE.chainwc(saveReview),
    RTE.tapRTE(() => sendConfirmationSoftFail(dto)),
  )
Enter fullscreen mode Exit fullscreen mode

This reads as a business recipe because every named step preserves a common protocol. Each produces a value in an RTE context. chain connects dependent work. A left value short-circuits the remaining steps. tapRTE adds a policy-controlled effect without changing the successful result.

Composition gives us more than visual neatness:

  • dependencies combine in the Reader context;
  • error types combine in the left channel;
  • execution remains lazy;
  • the success value changes predictably from step to step;
  • a step can be replaced without rewriting the orchestration mechanism.

Without referential transparency, composition is fragile. A function that appears to transform A into B but secretly mutates shared state cannot be safely reordered, repeated, cached, tested, or substituted.

What humans gain

Local reasoning

A reviewer can understand a pure function from its signature, implementation, and tests. They do not need to search for constructor mutations, lifecycle hooks, or hidden singletons.

This does not eliminate the need to understand the system. It reduces the radius of understanding required for a specific change.

Tests that state behavior directly

Pure domain tests are ordinary input/output assertions:

expect(calculateRemainingCredits(account, purchase)).toEqual(
  E.right(expectedBalance),
)
Enter fullscreen mode Exit fullscreen mode

Application programs need capabilities, but not a framework container:

const result = await pipe(
  updateProfile(dto),
  RTE.run({
    profiles: profileRepositoryDouble,
    embeddings: embeddingsServiceDouble,
    clock: fixedClock,
    logger: silentLogger,
  }),
  TE.run,
)
Enter fullscreen mode Exit fullscreen mode

The test record is executable documentation of what the use-case needs. There is no Nest testing module, decorator metadata, database, or global clock to configure.

Failures stop being folklore

With exceptions, callers learn failure behavior through documentation, experience, or production incidents. TaskEither<DatabaseError | RecordConflict, Profile> makes expected failures part of the interface.

The type does not guarantee good error design, but it prevents expected failure from being completely invisible.

Safer change through substitution

If two functions implement the same total contract, one can replace the other. That is the foundation of refactoring. It is also why capabilities expressed as records of functions are powerful: a real adapter and a test double are values satisfying the same interface.

Debugging with fewer possible histories

Mutable objects accumulate history. When a result is wrong, the question becomes “which earlier operation changed this object?” Pure transformations narrow the question to “which input or rule produced this output?”

That difference is enormous during incidents.

What AI coding agents gain

An AI model does not understand software exactly as a human does. It predicts and reasons over the context it has been given. Referential transparency improves the quality of that context.

Semantic compression

This signature:

ReaderTaskEither<PaymentContext, PaymentError, Receipt>
Enter fullscreen mode Exit fullscreen mode

compresses several facts into one place. An agent can infer that it must obtain PaymentContext, preserve the lazy computation, handle PaymentError, and produce Receipt. With an untyped async method, those facts may be distributed across constructors, thrown exceptions, mocks, and call sites.

A smaller search space

When a function depends only on its arguments, an agent can modify it using local evidence. Hidden global state forces repository-wide discovery and increases the number of plausible but incorrect changes.

This does not make repository exploration unnecessary. It makes the result of exploration more decisive.

Uniform transformation rules

map, chain, mapLeft, orElse, and provide form a small grammar. Once an agent identifies the current container and the next function's type, the set of valid transformations is constrained.

Regularity is valuable to humans; it is especially valuable to code-generation systems.

Verifiable output

Deterministic functions and plain capability records make it easier to generate focused tests. The agent can compare values instead of coordinating a large runtime environment.

Tests then become a feedback mechanism that rejects plausible-looking but semantically wrong code.

More reliable handoffs

Agents often operate in short sessions. Explicit pipelines preserve intent in code instead of requiring a previous agent's narrative. A named step such as assertProjectOwned communicates more durable information than an inline conditional embedded in a controller.

These are architectural inferences, not a productivity benchmark. Referential transparency does not make an agent correct. It removes classes of ambiguity that make both human and machine reasoning harder.

The audit also found broken transparency

The production system was not a functional utopia. Its deviations were some of the most useful findings.

Time and identity inside parsers

Several domain schemas supplied defaults such as:

createdAt: z.date().default(() => new Date())
Enter fullscreen mode Exit fullscreen mode

Some constructors generated IDs when none were provided. Parsing the same input twice could therefore produce different values.

That breaks substitution and mixes two responsibilities: validating data and creating new identity.

A clearer design keeps parsing deterministic and moves creation into a program with explicit capabilities:

const parseProfile = (input: unknown): E.Either<ValidationError, ProfileDraft> =>
  pipe(profileSchema.safeParse(input), fromZodResult)

const createProfile = (draft: ProfileDraft) =>
  pipe(
    RTE.ask<{ clock: Clock; ids: IdGenerator }>(),
    RTE.map(({ clock, ids }): Profile => ({
      ...draft,
      id: ids.next(),
      createdAt: clock.now(),
    })),
  )
Enter fullscreen mode Exit fullscreen mode

The clock and generator are still effectful. They are now visible, replaceable, and executed in the correct phase.

Imperative islands inside repositories

The audit found adapters containing try/catch, driver-specific branching, direct logging, and rethrowing inside an async block. Sometimes an external API makes a small imperative adapter unavoidable. The smell appears when the island also owns business policy and orchestration.

The remedy is not to ban try/catch cosmetically. It is to make the adapter small, convert its outcome immediately, and continue composition in TaskEither.

Pipelines can hide complexity too

A deeply nested pipe with several chain callbacks, inline conditions, and context construction is not automatically readable. Functional syntax can conceal control flow just as a large method can.

The successful pattern in the same codebase was a flat orchestration pipeline whose entries were named business steps. Referential transparency makes extraction safe; naming makes the result understandable.

Global loggers are hidden dependencies

Logging through tap may preserve the successful value, but it does not make the logger pure. A module-level logger is still hidden context. For important application flows, providing logging as a capability makes observability policy explicit and testable.

Referential transparency is not free

Functional architecture has costs.

  • Teams must learn the difference between map, chain, applicative composition, and execution.
  • Type errors involving nested contexts and error unions can be intimidating.
  • Excessive point-free style can remove useful names.
  • A generic effect stack can become ceremony around trivial code.
  • Poorly designed error unions can grow without a coherent recovery policy.
  • Laziness can surprise developers accustomed to eager Promises.

The answer is not maximum abstraction. It is the smallest abstraction that keeps important behavior explicit.

Use a plain function for a pure calculation. Use Either when synchronous validation can fail. Use TaskEither for lazy asynchronous failure. Add Reader when the computation genuinely needs capabilities. Keep framework classes where the framework requires them.

The goal is not to make every line look functional. The goal is to make business meaning substitutable and effects honest.

Working rules for human–AI codebases

The following rules survived contact with a large production system:

  1. Ask what is hidden. Treat time, randomness, configuration, logging, and mutable caches as dependencies.
  2. Separate parsing from creation. Validation should not silently generate identity or read the clock.
  3. Return descriptions of effects. Do not start Promises while building the program.
  4. Model expected failure as data. Reserve defects and truly unexpected failure for exceptions at the outer boundary.
  5. Name business steps. The main pipeline should read as a recipe, not an implementation puzzle.
  6. Decode at every trust boundary. HTTP bodies, database rows, queue messages, and AI responses are all untrusted values.
  7. Execute once. Supply the complete context and run the program at the controller, worker, CLI, or test boundary.
  8. Test substitution. Given fixed arguments and capabilities, repeated evaluation should produce the same observable value.
  9. Audit escape hatches. Every new Date(), random ID, global read, throw, and direct Promise is a useful search target.
  10. Optimize for the next reader. Humans and agents both benefit more from explicit names than clever combinator density.

The deeper payoff

Software maintenance is an exercise in prediction. Before changing a system, we try to predict what a piece of code means, what it can affect, and how the rest of the system will respond.

Referential transparency makes those predictions cheaper.

It does not eliminate effects. It gives them borders. It does not eliminate complexity. It lets complexity be composed from smaller contracts. It does not guarantee that humans or AI agents will write correct software. It gives both a codebase whose behavior is less dependent on invisible history.

That is why referential transparency is more than a functional-programming ideal. It is a collaboration protocol between the code, the person reading it today, and whatever intelligence must change it tomorrow.

For an end-to-end implementation, continue with Functional Clean Architecture and the runnable Opinionated Backend Architecture example.

Top comments (0)