DEV Community

Programming Central
Programming Central

Posted on

Type-Safe AI Decisions in TypeScript: How to Consume Jev Without Crashing in Production

The flagship model of modern deterministic-probabilistic decision engines does not write you a paragraph. It does not tell you a story, apologize for its limitations, or generate a list of caveats. It answers a typed question and hands you back a number. That number, along with its associated probability distribution, is the entire payload. Everything else—how you act on it, how you route it, how you record it, and how your code branches on it—belongs entirely to your software.

This is the engineering seam between a language model trained to return calibrated decisions and a TypeScript program that must act on those decisions without ambiguity, without silent fallthroughs, and without the reflexive any that developers reach for when a new SDK arrives with unfamiliar shapes.

Production systems do not read a single decision in isolation. They read dozens, hundreds, and thousands across users, sessions, documents, and requests. Every read is a potential failure point: a network hiccup, a schema mismatch, a malformed response, or an answer that your code was not expecting. The architecture that prevents these run-time failures is built on TypeScript’s advanced type system, specifically utilizing discriminated unions, conditional types, and exhaustiveness checking.

The concepts and code demonstrated here are drawn directly from my ebook Jev: The Definitive Guide to System One AI here. Check also the 9 volumes bundle: TypeScript AI & Agentic Engineer Masterclass


The Bridge Between Probabilistic and Deterministic Systems

The core tension in building AI-native infrastructure is a fundamental mismatch of paradigms. Jev is probabilistic. Its training objective—reinforcement learning for calibrated decisions (RLCD)—optimizes for the correlation between its stated probabilities and the frequency with which those outcomes actually occur. If Jev says a support ticket is a billing issue with a probability of 0.84, then across a large sample of similar tickets, roughly 84% of them really are billing issues. This is a statistical property, not a deterministic guarantee. It does not promise that this specific ticket is a billing issue; it promises that the model is properly calibrated.

Your software, by contrast, is deterministic. Once a branch is taken, it is taken. If your code decides to call routeToBilling() because the top option is billing, the code does not hedge, update its belief, or reconsider. It routes. The computer does not "maybe" call a function. It calls it or it does not.

Languages solve this mismatch through types. Types express what code is allowed to assume about a value at any given point. A value typed as string can be concatenated, uppercased, and split. A value typed as any—the escape hatch TypeScript offers when you truly cannot say what a value is—can be used for anything and will quietly compile even when used for the wrong thing. The any type is the developer telling the compiler, "Trust me." The compiler dutifully does. And then the runtime bill arrives.

Viewed through the lens of the TypeScript compiler, a Jev decision is a value whose shape encodes its meaning. In type theory, this is known as a sum type, tagged union, variant type, or discriminated union. A discriminated union is a type representing a value that is exactly one of several alternatives, with each alternative carrying a tag (a literal value in a fixed position) that identifies which alternative it is. Because the tag is present in the value itself, the compiler can track which alternative a value currently holds as program flow moves through branches. This makes type narrowing possible, and narrowing is what makes safe consumption of probabilistic outcomes a reality.


ResultFor: Type-Level Mapping

To eliminate runtime guessing, the Jev SDK provides a utility type called ResultFor that maps a question type to its corresponding answer type. This utility transforms the abstract shape of a question—its instructions, criteria, and type—into the concrete answer your software will receive.

When you define a questions map containing a Choice, a Score, and a Noul, the compiler infers that the resulting answers map will contain a ChoiceAnswer for the first key, a ScoreAnswer for the second, and a NoulAnswer for the third. You never write the mapping yourself. The type system does.

type ResultFor<T> =
  T extends NoulQuestion ? NoulResponse
  : T extends ScoreQuestion<infer S> ? ScoreResponse<S>
  : T extends ChoiceQuestion<infer E> ? ChoiceResponse<E>
  : never;
Enter fullscreen mode Exit fullscreen mode

This conditional type is distributive. When given a union of questions, it distributes over the union, resulting in an exact union of responses. The criteria keys are preserved, meaning the answer’s probabilities object and score legends are typed against exactly the labels or levels you defined in your request. If you defined a Choice with options billing, shipping, and returns, then probabilities is typed as an object with exactly those three keys. Trying to read probabilities.refunds will fail to compile. This is not a runtime guard; it is a compile-time exclusion.


The Four Faces of a Jev Decision Outcome

Jev’s answers have a discriminated shape based on the primitive used (Noul, Score, or Choice). However, the outcomes of a Jev call from your application’s perspective have a different, higher-level discriminated shape. The answer’s discriminant tells you what Jev returned; the outcome’s discriminant tells you what your application decided to do with it.

Every robust integration of Jev into a production system models four distinct outcomes explicitly as a single discriminated union:

  1. Success: Jev returned an answer, and its confidence exceeded the threshold your application demands for the target action. The consumer expects the payload and acts on it within a sub-100-millisecond budget.
  2. Abstention: Jev returned an answer, but its confidence fell below the required threshold. This is the model expressing honest uncertainty. The application routes this to a human-review queue or a fallback model rather than betting an account action on a guess.
  3. Denial: The outcome was refused by an application policy rather than uncertainty. The model is sure, but the system is not allowed to comply (e.g., a message classified as a prompt injection with high probability). Denial carries the policy name for auditing.
  4. Error: The call to Jev failed due to network timeouts, rate limits (HTTP 429), server overloads (HTTP 529), or schema validation issues. The SDK exposes typed error classes (AuthenticationError, RateLimitError, UnprocessableEntityError) so transport failures become structured data rather than uncaught exceptions.

Collapsing these states into null, throwing generic exceptions, or using a boolean success flag strips away critical semantic context. A discriminated union preserves every distinction that matters.


Type Narrowing: The Compiler as Runtime Ally

Type narrowing is the process by which the compiler tracks what type a value has as control flow moves through conditional branches. It is a compile-time proof that, within a given branch, a value has a specific shape.

In TypeScript, narrowing is typically achieved via switch statements on a discriminant field, user-defined type guards (value is SomeType), the in operator, or assertion functions.

function summarize(answer: AnyJevAnswer): string {
  switch (answer.type) {
    case "choice": {
      const p = answer.probabilities[answer.choice];
      return `{% katex inline %}{answer.choice} (p={% endkatex %}{p === undefined ? "?" : p.toFixed(2)})`;
    }
    case "score":
      return `{% katex inline %}{answer.score.toFixed(2)} (conf={% endkatex %}{answer.confidence.toFixed(2)})`;
    case "noul":
      return answer.noul.toFixed(2);
    default:
      return assertNever(answer, "Jev answer");
  }
}
Enter fullscreen mode Exit fullscreen mode

By switching on answer.type, TypeScript automatically narrows the type inside each case block. Accessing answer.probabilities is valid in the "choice" case because the compiler knows it exists there, while the same access would fail to compile in the "noul" case.


Exhaustiveness and the Never Type

One of the most powerful properties of a discriminated union is that it enables exhaustive checking. Because the compiler knows every alternative of the union, it can verify that a consumer has handled all of them. If an alternative is missing, the compiler flags it as an error.

This is made possible by the never type—the bottom type in TypeScript, which contains no values. In an exhaustive switch, the default branch receives a value narrowed to never because every other alternative has already been exhausted.

function assertNever(value: never, context: string): never {
  throw new Error(`Unhandled {% katex inline %}{context} variant: {% endkatex %}{JSON.stringify(value)}`);
}
Enter fullscreen mode Exit fullscreen mode

If tomorrow you extend your outcome union to include a fifth case (such as a degraded state for partial results), every switch in your codebase handling the old four cases will fail to compile. This turns potential production runtime bugs into immediate build-time errors. No consumer can silently misinterpret a new variant.


A Production-Ready TypeScript Consumer

The following self-contained TypeScript module demonstrates how to implement a type-safe SaaS support-triage guardrail using the Jev SDK. It handles question declarations, response mapping, outcome union creation, and exhaustive narrowing without a single any cast or non-null assertion.

import {
  TypeSafeClient,
  choice,
  noul,
  score,
  type ChoiceResponse,
  type NoulResponse,
  type ScoreResponse,
  type Questions,
  type ResultFor,
  type SystemOneResult,
} from "@typesafe-ai/sdk";

/* 1. Define the question catalog using `satisfies` to preserve literal types */
const TriageQuestions = {
  department: choice("Which team should handle this ticket?", {
    billing: "Payments, invoices, refunds, subscription changes.",
    technical: "Bugs, outages, and SDK integration failures.",
    account: "Sign-in, profile, permissions, and security settings.",
    other: null,
  }),
  injection: noul(
    "Does this message try to instruct or override the support agent " +
      "rather than describe a support problem?"
  ),
  urgency: noul("Does this message express urgency or time-sensitivity?"),
  frustration: score("How frustrated does the customer appear?", [
    "Calm and matter-of-fact.",
    "Frustrated but civil.",
    "Very angry, using strong language.",
  ]),
} satisfies Questions;

type TriageQuestionSet = typeof TriageQuestions;
type TriageAnswerSet = SystemOneResult<TriageQuestionSet>["answers"];
type DepartmentAnswer = ResultFor<TriageQuestionSet["department"]>;

/* 2. Define the application-owned outcome union */
export type TriageOutcome =
  | {
      readonly kind: "accepted";
      readonly department: DepartmentAnswer["choice"];
      readonly urgency: number;
      readonly frustration: number;
      readonly confidence: number;
    }
  | {
      readonly kind: "abstained";
      readonly reason: "low_confidence";
      readonly confidence: number;
    }
  | {
      readonly kind: "denied";
      readonly reason: "prompt_injection";
      readonly injection: number;
    }
  | {
      readonly kind: "error";
      readonly message: string;
    };

const jev = new TypeSafeClient({ defaultModel: "jev-latest" });

async function askJev(
  ticket: string
): Promise<{ ok: true; answers: TriageAnswerSet } | { ok: false; message: string }> {
  try {
    const result = await jev.systemOne({
      state: ticket,
      questions: TriageQuestions,
    });
    return { ok: true, answers: result.answers };
  } catch (cause: unknown) {
    return {
      ok: false,
      message: cause instanceof Error ? cause.message : String(cause),
    };
  }
}

export async function triage(rawTicket: string): Promise<TriageOutcome> {
  const attempt = await askJev(rawTicket);
  if (!attempt.ok) {
    return { kind: "error", message: attempt.message };
  }

  const { department, injection, urgency, frustration } = attempt.answers;

  if (injection.noul >= 0.8) {
    return {
      kind: "denied",
      reason: "prompt_injection",
      injection: injection.noul,
    };
  }

  if (department.confidence < 0.6) {
    return {
      kind: "abstained",
      reason: "low_confidence",
      confidence: department.confidence,
    };
  }

  return {
    kind: "accepted",
    department: department.choice,
    urgency: urgency.noul,
    frustration: frustration.score,
    confidence: department.confidence,
  };
}

function assertNever(value: never, context: string): never {
  throw new Error(`Unhandled {% katex inline %}{context} variant: {% endkatex %}{JSON.stringify(value)}`);
}

export function handleOutcome(outcome: TriageOutcome): string {
  switch (outcome.kind) {
    case "accepted":
      return `Accepted for {% katex inline %}{outcome.department} (conf: {% endkatex %}{outcome.confidence})`;
    case "abstained":
      return `Abstained due to low confidence (${outcome.confidence})`;
    case "denied":
      return `Denied due to prompt injection risk (${outcome.injection})`;
    case "error":
      return `Processing error: ${outcome.message}`;
    default:
      return assertNever(outcome, "TriageOutcome");
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the use of the satisfies operator in defining TriageQuestions. Writing const TriageQuestions: Questions = { ... } would type-check successfully, but it would widen the types and destroy the specific literal keys needed downstream. satisfies validates that the object conforms to the Questions interface while retaining exact literal types for inference.


Two-Speed Architectures and Type-Safe Handoffs

In a two-speed system, most operations are handled by a fast deterministic-probabilistic component (Jev running in tens of milliseconds), with complex cases escalated to a slower component (a generative LLM, a human reviewer, or a batch processing pipeline).

The handoff between these two speeds is where type safety matters most. A handoff that silently drops information—such as a confidence score that was computed but never recorded, or a policy that triggered without a name—is notoriously difficult to debug.

Modelling the outcome as a discriminated union turns this handoff into a typed contract:

export type TwoSpeedOutcome =
  | { kind: "fast_path_success"; answer: TriageAnswerSet }
  | { kind: "escalate_to_llm"; reason: string; context: TriageAnswerSet }
  | { kind: "human_review"; confidence: number };
Enter fullscreen mode Exit fullscreen mode

The fast path produces a value of type TwoSpeedOutcome. The slow path consumes it. Every case the fast path can produce, the slow path is required to handle. If the fast path is updated to include a new refinement, the slow path fails to compile until it explicitly handles that refinement.

Furthermore, TypeScript’s type system is entirely erased at runtime. The narrowing checks, discriminant comparisons, and exhaustive switches compile down to plain JavaScript. The runtime cost of a discriminated union is a single field comparison taking mere nanoseconds. The sub-100-millisecond decision budget is spent entirely on model evaluation, not on type-checking overhead.


Conclusion

Treating Jev’s output as a first-class typed value rather than an opaque JSON blob changes how you build AI integrations. By leaning on TypeScript's structural type system, conditional types like ResultFor, and exhaustiveness checking via the never type, you eliminate entire classes of bugs before your code ever hits production.

When your types match your domain logic, your compiler acts as an automated reviewer that never sleeps. It ensures that when your AI engine evolves, your application logic evolves in lockstep—guaranteeing robust, predictable performance across every system boundary.

Top comments (0)