DEV Community

Cover image for Guardrails in TypeScript: Input Validation, Output Filters, Refusal Paths
Gabriel Anhaia
Gabriel Anhaia

Posted on

Guardrails in TypeScript: Input Validation, Output Filters, Refusal Paths


Guardrails usually start as a paragraph in the system prompt. "Never
reveal internal pricing. Never give legal advice. Always redact card
numbers."

That is worth having and it is not a control. It is a request to a
probabilistic system, with no enforcement, no logging, and no way to
tell whether it held on any particular request.

The version that is a control is ordinary middleware — code that runs
before and after the model, that you can test, and that produces a
typed outcome your caller has to handle.

Nothing here makes a system safe. Each stage reduces a specific class
of risk, and they compose.

The pipeline type

export type Refusal = {
  kind: "refusal";
  stage: string;
  code: RefusalCode;
  userMessage: string;
  internal: string;
};

export type Guarded<T> =
  | { ok: true; value: T }
  | { ok: false; refusal: Refusal };

export type InputStage = (
  req: AgentRequest,
) => Promise<Guarded<AgentRequest>>;

export type OutputStage = (
  out: AgentOutput,
  req: AgentRequest,
) => Promise<Guarded<AgentOutput>>;
Enter fullscreen mode Exit fullscreen mode

A stage takes a value and returns either the value — possibly
modified — or a refusal. Because stages transform, an input stage can
redact rather than reject, which is what you want for PII.

userMessage and internal are separate on purpose. One is safe to
show; the other has the detail you need in a log and must never reach
a user.

Composing stages

export function pipeline<T, C>(
  stages: ((v: T, ctx: C) => Promise<Guarded<T>>)[],
) {
  return async (value: T, ctx: C): Promise<Guarded<T>> => {
    let current = value;
    for (const stage of stages) {
      const res = await stage(current, ctx);
      if (!res.ok) return res;
      current = res.value;
    }
    return { ok: true, value: current };
  };
}
Enter fullscreen mode Exit fullscreen mode

Sequential, short-circuiting, order-dependent — and order matters.
Redaction must run before anything that logs. Length checks should
run before expensive classification. Putting the order in one array
makes it reviewable:

const guardInput = pipeline<AgentRequest, Ctx>([
  lengthLimit,
  languageCheck,
  redactPii,
  topicClassifier,
]);
Enter fullscreen mode Exit fullscreen mode

Input stages

const lengthLimit: InputStage = async (req) => {
  const tokens = estimateTokens(req.message);
  if (tokens > 8_000) {
    return {
      ok: false,
      refusal: {
        kind: "refusal", stage: "lengthLimit", code: "TOO_LONG",
        userMessage: "That message is too long. Send under ~6,000 words.",
        internal: `estimated ${tokens} tokens`,
      },
    };
  }
  return { ok: true, value: req };
};
Enter fullscreen mode Exit fullscreen mode

Cheap and deterministic first. A length check costs nothing and
prevents a large bill from a paste of an entire document.

Redaction transforms rather than refuses:

const redactPii: InputStage = async (req) => {
  const { text, found } = redact(req.message, [
    { name: "card", re: /\b(?:\d[ -]*?){13,19}\b/g },
    { name: "iban", re: /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/g },
  ]);

  if (found.length) {
    metrics.increment("guardrail.pii_redacted", found.length);
  }
  return { ok: true, value: { ...req, message: text, redacted: found } };
};
Enter fullscreen mode Exit fullscreen mode

Regex PII detection catches obvious formats and misses plenty. It is
a reduction in exposure, not a guarantee — treat it as one layer, and
do not let its presence justify logging raw input elsewhere.

Note the metric. A guardrail that fires silently tells you nothing;
the rate at which each stage triggers is the only way to know whether
they are doing anything.

Input stages transforming and short-circuiting before the model call.

Output stages

Output is where the interesting checks live, because they can inspect
what the model actually produced against what it was given.

const schemaCheck: OutputStage = async (out) => {
  const parsed = AnswerSchema.safeParse(out.structured);
  if (!parsed.success) {
    return refuse("schemaCheck", "MALFORMED",
      "I could not produce a valid answer. Try rephrasing.",
      parsed.error.message);
  }
  return { ok: true, value: { ...out, structured: parsed.data } };
};

const noInternalLeaks: OutputStage = async (out) => {
  const hits = FORBIDDEN.filter((p) => p.test(out.text));
  if (hits.length) {
    return refuse("noInternalLeaks", "LEAK",
      "I cannot share that information.",
      `matched ${hits.length} forbidden patterns`);
  }
  return { ok: true, value: out };
};

const citationsRequired: OutputStage = async (out, req) => {
  if (!req.requiresCitations) return { ok: true, value: out };
  const uncited = out.claims.filter((c) => c.sources.length === 0);
  if (uncited.length) {
    return refuse("citationsRequired", "UNCITED",
      "I could not find sources for part of that answer.",
      `${uncited.length} uncited claims`);
  }
  return { ok: true, value: out };
};
Enter fullscreen mode Exit fullscreen mode

citationsRequired is the one worth copying. It enforces a property
of the answer against a requirement of the request, which no prompt
instruction can guarantee — and it catches the specific failure where
a model fills a gap with plausible invention.

Refusal is a value, not an exception

export async function ask(req: AgentRequest, ctx: Ctx) {
  const checked = await guardInput(req, ctx);
  if (!checked.ok) return checked;

  const raw = await runAgent(checked.value, ctx);

  return guardOutput(raw, checked.value, ctx);
}
Enter fullscreen mode Exit fullscreen mode

The caller receives Guarded<AgentOutput> and cannot ignore it —
ok must be narrowed before value is reachable. That is the
difference between a guardrail and a hope: the compiler requires the
refusal path to be handled.

const res = await ask(req, ctx);
if (!res.ok) {
  return <RefusalNotice message={res.refusal.userMessage} />;
}
return <Answer data={res.value} />;
Enter fullscreen mode Exit fullscreen mode

Throwing would work too, until someone adds a catch that logs and
continues.

Log refusals as data

function refuse(stage: string, code: RefusalCode,
                userMessage: string, internal: string): Guarded<never> {
  logger.info("guardrail refusal", { stage, code, internal });
  metrics.increment(`guardrail.refusal.${stage}`);
  return { ok: false, refusal: { kind: "refusal", stage, code,
                                 userMessage, internal } };
}
Enter fullscreen mode Exit fullscreen mode

The per-stage counter is what tells you whether your guardrails are
calibrated. A stage that never fires is either unnecessary or broken,
and you cannot distinguish those without the number. A stage that
fires constantly is refusing legitimate use, and users experience
that as the product being unhelpful.

Both failure modes are invisible without instrumentation, and both
are common.

Refusal rates per stage, distinguishing a dead guardrail from an over-tuned one.

Testing them

describe("noInternalLeaks", () => {
  it("refuses output containing internal pricing", async () => {
    const res = await noInternalLeaks(withText("cost basis is 12%"), req);
    expect(res.ok).toBe(false);
    if (!res.ok) expect(res.refusal.code).toBe("LEAK");
  });

  it("passes ordinary answers through unchanged", async () => {
    const out = withText("Your plan renews on the 12th.");
    const res = await noInternalLeaks(out, req);
    expect(res.ok).toBe(true);
    if (res.ok) expect(res.value).toEqual(out);
  });
});
Enter fullscreen mode Exit fullscreen mode

Both directions. The false-positive test is the one people skip and
the one that catches an over-broad pattern before it starts refusing
real questions.

What this does not do

It reduces specific risks. It does not make the system safe.

Regex PII detection misses unusual formats. A forbidden-phrase filter
misses paraphrase. A schema check confirms shape, never truth. And
none of it addresses an agent being manipulated into taking a harmful
action — that requires constraining what tools can do, which is a
different layer.

The value of writing them as middleware is not that the coverage is
complete. It is that each check is explicit, ordered, tested, and
measured — so you know what you have rather than hoping a paragraph
held.


If this was useful

AI That Ships covers
guardrails as engineering — composable stages, typed refusals, PII
handling, output verification, and the metrics that tell you whether
any of it is calibrated.

AI That Ships — Evals, Guardrails, Cost Control, and Deploying AI Agents on Node.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)