Enforcing audit logging + authz at compile-time

Compile-time and runtime guarantees for audit logging and authorization in TypeScript

The problem

Audit logging and authorization checks are critical in high-compliance features. Forgetting either one can allow critical bugs to slip through undetected.

TypeScript can enforce these requirements at compile time while preserving developer flexibility. Here are four approaches, from weakest to strongest.

Airport analogy

Before we dive into technical details, let’s use the analogy of boarding a flight, each approach maps to a level of airport security.

  1. No checking: You walk into the airport and straight onto the plane. Everyone trusts that travelers did what they were supposed to do.
  2. Boarding pass issued: You receive a boarding pass reminding you to go through security. Nobody stops you if you skip it.
  3. Mandatory checkpoint: A single security gate blocks the entrance. You can’t miss it, but it’s one-size-fits-all: the same screening for every traveler, with no way to handle varied requirements.
  4. Boarding pass stamped: Before you board, the gate agent checks for a stamp you can only get from security checkpoints. You can’t board without the stamp, but you have the flexibility to choose when and where you clear security (e.g. metal detector vs full-body scan).
  5. Barcode verified: The boarding pass carries a barcode. The gate agent scans it for details on your security check: not just proof you went through, but exactly when and how.

Approaches

Terminology: we’ll use existing layer naming and say we have:

1. Requirements only

The Effect type has three channels: success value, error, and requirements: Effect<T, E, R>.

The requirements channel can remind developers to include certain services.

type TicketManagerRepo = {
  list: () => Effect.Effect<
    { id: number; status: string }[],
    TicketManagerError,
    AuditAccessRepo // requirement
  >;
};

Running this Effect without providing AuditAccessRepo produces a type error. The requirements channel is not fulfilled.

const TicketManagerDummy = Layer.succeed(TicketManagerRepo, {
  list: () =>
    Effect.gen(function* () {
      const auditAccess = yield* AuditAccessRepo;

      const res = [{ id: 1, status: "open" }];

      yield* auditAccess.log({ action: "ticketList" });

      return res;
    }), // no AuditAccessRepo implementation provided
});

export const loader = () =>
  Effect.gen(function* () {
    const ticketManager = yield* TicketManagerRepo;
    const res = yield* ticketManager.list();
    return res;
  }).pipe(Effect.provide(TicketManagerDummy)); // ❌ Type 'AuditAccessRepo' is not assignable to type 'never'.

This buys limited type safety. TypeScript will not complain if you provide AuditAccessRepo but never call it. As long as the implementation is supplied, the types pass.

const TicketManagerDummy = Layer.succeed(TicketManagerRepo, {
  list: () => Effect.succeed([{ id: 1, status: "open" }]), // doesn’t use AuditAccessRepo
}).pipe(Layer.provide(AuditAccessDummy)); // provide AuditAccessRepo implementation

const blueprint = Effect.gen(function* () {
  const ticketManager = yield* TicketManagerRepo;
  const res = yield* ticketManager.list();
  console.log(res);
}).pipe(Effect.provide(TicketManagerDummy));

await Effect.runPromise(blueprint); // ✅ passes type check even though AuditAccessRepo is not used

Why this happens

The R (requirements) type parameter in Effect is covariant. never is a subtype of everything, so Effect<A, never, never> is a subtype of Effect<A, TicketManagerError, AuditAccessRepo>. An effect with no requirements satisfies a type that expects requirements.

This limitation isn’t Effect-specific. TypeScript’s function parameter arity rules create a similar gap. A function can declare a parameter in its type signature yet omit it in its implementation without a type error.

// We declare that listTickets needs an audit logger
type ListTicketsFn = (auditLogger: AuditLogger) => string[];

// no type errors -- TypeScript allows fewer parameters than declared
const listTickets: ListTicketsFn = () => {
  return ["data"];
};

2. Higher-order function

A helper function can force developers to attach an audit log callback and an authz policy.

const TicketManagerDummy = {
  list: {
    policy: TicketAuthzEngine.canList(),
    execute: () => {
      const res = [{ id: 1, status: "open" }];
      return res;
    },
    audit: (res) => auditAccess.log({ action: "ticketList", data: res }),
  },
};

This works when auditing and authz live outside the business logic. Authz already follows this pattern if using Policy.guard (see Composable authorization with Effect).

If audit logging is part of the business logic though, developer flexibility suffers.

const TicketManagerDummy = {
  list: {
    policy: TicketAuthzEngine.canList(),
    execute: (filters: TicketFilters) => {
      auditAccess.log({ action: "query", filters });

      const tickets = ticketAccess.list(filters);

      const restricted = tickets.filter(
        (t) => t.classification === "restricted"
      );
      if (restricted.length > 0) {
        auditAccess.log({
          action: "restrictedDataAccess",
          ticketIds: restricted.map((t) => t.id),
        });
      }

      auditAccess.log({ action: "ticketList", count: tickets.length });

      return tickets;
    },
    audit: () => {
      /* ??? */
    },
  },
};

3. Compile-time output check (type-only)

Enforcement improves when the return type demands concrete proof. Let’s design a system so the only way to obtain that proof is by calling the required functions.

We’ll start with a type-only token using a flavor/branded type (for implementation details, see flavored types for financial systems).

type AuditProof = Flavor<"AuditProof">; // compile-time only, no runtime value

Consumers cannot import this type directly, but AuditAccess methods return it.

type AuditAccessRepo = {
  log: (event: string) => AuditProof;
};

Next, we’ll define an output type that intersects the proof with the actual return value.

type WithProof<T> = T & AuditProof;

The “gate” accepts the proof and is the only way to produce a WithProof value.

// _auditProof is not used at runtime, just enforced by the type system
export const withProof = <T>(data: T, _auditProof: AuditProof) =>
  data as WithProof<T>;

Using WithProof<T> as the return type enforces that AuditAccess is both imported and called. It’s the only way to obtain an AuditProof token.

// wrap return type with `WithProof`
type ListFn = () => WithProof<{ id: number; status: string }[]>;

const list: ListFn = () => {
  const res = ticketAccess.list();

  // return res; // ❌ type error

  const proof = auditAccess.log("ticketList");

  return withProof(res, proof);
};

Conditional branches and helper functions work fine. As long as every return path produces an AuditProof, TypeScript catches any missing AuditAccess call.

4. Runtime output check (actual values)

When runtime validation matters (e.g. counting audit calls or storing metadata), use real values instead of type-only brands.

const AUDIT_PROOF_SYMBOL: unique symbol = Symbol.for("AuditProof");

type AuditProof = {
  [AUDIT_PROOF_SYMBOL]: {
    timestamp: number;
  };
};

type WithProof<T> = { data: T; proof: AuditProof };

// Only way to produce AUDIT_PROOF_SYMBOL
// Used by the AuditAccess implementation
const makeAuditProof = (): AuditProof => ({
  [AUDIT_PROOF_SYMBOL]: {
    timestamp: Date.now(),
  },
});

const list: ListTicketsFn = () => {
  const data = [{ id: 1, status: "open" }];
  const proof = auditAccess.log("ticketList");
  return { data, proof }; // `proof` is inspectable at runtime
};

A helper function handles runtime checks and unwraps the data field.

const enforce = <T>(fn: () => WithProof<T>): T => {
  const { data, proof } = fn();
  if (!proof[AUDIT_PROOF_SYMBOL]) {
    throw new ProofError("No proof found");
  }
  return data;
};

// return value is unwrapped
const listTickets = enforce(() => {
  const data = [{ id: 1, status: "open" }];
  const proof = auditLog("ticketList");
  return { data, proof };
});

This extends naturally to richer runtime checks. Let’s add an action field to AuditProof and collect an array of proofs. An enforceAll helper validates that specific required actions were recorded.

const AUDIT_PROOF_SYMBOL: unique symbol = Symbol.for("auditProof");

type AuditProof = {
  [AUDIT_PROOF_SYMBOL]: {
    action: string;
    timestamp: number;
  };
};

type WithProof<T> = { data: T; proofs: AuditProof[] };

const enforceAll = <T>(required: string[], fn: () => WithProof<T>): T => {
  const { data, proofs } = fn();

  for (const action of required) {
    if (!proofs.some((p) => p[AUDIT_PROOF_SYMBOL].action === action)) {
      throw new ProofError(`Missing required audit proof: ${action}`);
    }
  }

  return data;
};

const tickets = enforceAll(["query", "ticketList"], () => {
  const queryProof = auditAccess.log({ action: "query", filters });
  const data = ticketAccess.list(filters);
  const listProof = auditAccess.log({
    action: "ticketList",
    count: data.length,
  });
  return { data, proofs: [queryProof, listProof] };
});

Other approaches

  • Custom linting rules could enforce WithProof usage in certain packages. Feasible, but hard to cover all edge cases.
  • A Proxy class could wrap methods and verify audit/authz calls at runtime. This provides no compile-time safety.

Caveats

Effect.Service does not enforce return types on its methods when you pass a contract as the type parameter. The type parameter controls the shape of the service interface, not the shape of the implementation.

type MyContract = {
  foo: () => number;
};

class MyService extends Effect.Service<MyContract>()("Service", {
  succeed: {
    foo: () => "hello", // no type error
  },
}) {}

Two workarounds exist.

Context.Tag with Layer.succeed checks the implementation against the contract at the call site:

class MyContext extends Context.Tag("MyContext")<MyContext, MyContract>() {}

const myLayer = Layer.succeed(MyContext, {
  foo: () => "hello", // type error: Type 'string' is not assignable to type 'number'.
});

If you prefer Effect.Service, add satisfies to the implementation object. You can reference the contract type directly or pull it from an existing tag’s Type field:

class MyService extends Effect.Service<MyService>()("Service", {
  succeed: {
    foo: () => "hello", // type error: Type 'string' is not assignable to type 'number'.
  } satisfies MyContract,
}) {}

// or, if you already have a Context.Tag:
class MyService extends Effect.Service<MyService>()("Service", {
  succeed: {
    foo: () => "hello", // type error: Type 'string' is not assignable to type 'number'.
  } satisfies MyContext["Type"],
}) {}