TypeScript AI PR Reviewer: Catch Unsafe Types
π Technical Briefing: This tutorial is part of our deep-dive series on Agentic Workflows at Gate of AI. For the full technical breakdown, interactive code sandbox, and the native Arabic translation, visit the original article here. Build a TypeScript pull-request reviewer that combines Git diffs, TypeScript Compiler API diagnostics, local AST rules, and OpenAI structured output. The result is a small CLI that can run locally or in CI while keeping compiler failures separate from contextual AI feedback. What You Will Build TypeScript is valuable because it makes contracts visible before code runs. A pull request can weaken those contracts without producing an immediate compiler error: a new any annotation can erase checking at a boundary, an assertion through unknown can force incompatible values into a trusted type, and a diagnostic suppression can hide a real mismatch. These patterns are not always defects, but they deserve deliberate review. This tutorial builds type-guardian , a command-line reviewer for the current Git branch. It follows a layered approach: - The Git diff defines the pull-request scope. - TypeScript AST rules identify narrow, deterministic policy patterns. - The TypeScript Compiler API collects pre-emit diagnostics from tsconfig.json . - OpenAI supplies contextual review findings in validated structured JSON. The compiler remains authoritative for TypeScript errors. Local rules remain authoritative for policies such as reporting @ts-ignore . The model is useful for explaining a potentially unsafe boundary or spotting context that a narrow syntax rule cannot establish. It should not silently change code, bypass the compiler, or become the only merge gate. This division is particularly useful for engineering teams in the GCC and Middle East that are scaling AI-assisted software delivery alongside governance requirements. Organizations contributing to initiatives such as Saudi Vision 2030 or the UAE National Strategy for Artificial Intelligence can apply the same pattern: enforce deterministic engineering controls locally, then enable external contextual review only after deciding what source material is permitted to leave the development environment. Prerequisites and Project Setup You need a TypeScript repository with Git and a tsconfig.json , plus an OpenAI API key if you intend to run the AI phase. The code uses ECMAScript modules and the current OpenAI JavaScript SDK direction: the Responses API. The TypeScript Compiler API is a suitable foundation for source parsing and diagnostics; it is also used in published technical work to parse TypeScript declaration files and model type information. mkdir type-guardian cd type-guardian npm init -y npm install openai dotenv zod npm install --save-dev typescript tsx vitest @types/node npm pkg set type=module npm pkg set scripts.build="tsc -p tsconfig.json" npm pkg set scripts.review="tsx src/index.ts --base origin/main" npm pkg set scripts.test="vitest run" mkdir src test Create .env locally. Do not commit it. In CI, inject the key using the CI platformβs secret mechanism. A diff can contain credentials, customer identifiers, generated data, or internal implementation details, so this tutorial deliberately limits the material included in the external request. OPENAI_API_KEY=your-api-key OPENAI_MODEL=gpt-5.6 TYPE_GUARDIAN_MAX_DIFF_CHARS=24000 TYPE_GUARDIAN_MAX_FILES=30 TYPE_GUARDIAN_FAIL_ON=high node_modules/ dist/ .env .env.* coverage/ The model name is configurable because availability and organizational approval vary. Check the current OpenAI model guidance before selecting a production model. Use --no-ai when you want a fully local compiler-and-policy review. Step 1: Define Strict Compiler Settings and Shared Types Create tsconfig.json . The strict settings are intentional: a tool that reports unsafe assumptions should itself make optional values and unknown errors explicit. { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "./src", "outDir": "./dist", "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "useUnknownInCatchVariables": true, "skipLibCheck": true }, "include": ["src/**/.ts"] } Now create src/types.ts . These types are the contract shared by local analysis, AI analysis, terminal output, and future CI reporting. export type Severity = "low" | "medium" | "high" | "critical"; export type FindingCategory = | "explicit-any" | "unsafe-type-assertion" | "typescript-suppression" | "compiler-error" | "ai-review"; export interface SourceLocation { file: string; line: number; column: number; } export interface Finding { id: string; severity: Severity; category: FindingCategory; title: string; explanation: string; recommendation: string; evidence: string; location: SourceLocation; confidence: number; } export interface ChangedFile { path: string; patch: string; } export interface ReviewOptions { baseRef: string; maxDiffChars: number; maxFiles: number; includeAiReview: boolean; } export interface ReviewReport { generatedAt: string; baseRef: string; changedFiles: number; compilerDiagnostics: number; aiReviewIncluded: boolean; findings: Finding[]; } Locations are one-based because that is the convention developers see in terminals and code-hosting interfaces. The Compiler API uses positions that must be converted at the integration boundary. Confidence is a number from zero to one: deterministic syntax matches can be assigned high confidence, while AI findings remain evidence for a reviewer to assess. Step 2: Read the Diff and Run Deterministic Checks Create src/analyze.ts . This file obtains changed TypeScript files from the merge-base comparison, visits the current working-tree source with the TypeScript parser, and obtains compiler diagnostics from the repository configuration. The triple-dot range, base...HEAD , is appropriate for the common pull-request comparison against the merge base. import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import ts from "typescript"; import type { ChangedFile, Finding, FindingCategory, ReviewOptions, Severity, } from "./types.js"; function runGit(args: string[]): string { try { return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); throw new Error(Git command failed: git ${args.join(" ")}: ${message}); } } function finding( category: FindingCategory, severity: Severity, file: string, line: number, title: string, explanation: string, recommendation: string, evidence: string, confidence: number, ): Finding { return { id: ${category}:${file}:${line}:${title}, category, severity, title, explanation, recommendation, evidence: evidence.trim().slice(0, 500), location: { file, line, column: 1 }, confidence, }; } export function getChangedFiles(options: ReviewOptions): ChangedFile[] { const paths = runGit([ "diff", "--name-only", "--diff-filter=ACMR", ${options.baseRef}...HEAD, "--", ".ts", "*.tsx", ]) .split(/\r?\n/) .map((value) => value.trim()) .filter(Boolean) .slice(0, options.maxFiles); return paths.map((file) => ({ path: file, patch: runGit(["diff", "--unified=3", ${options.baseRef}...HEAD, "--", file]), })); } export function findLocalPolicyViolations(files: ChangedFile[]): Finding[] { const results: Finding[] = []; for (const file of files) { if (!existsSync(file.path)) continue; const text = readFileSync(file.path, "utf8"); const lines = text.split(/\r?\n/); const source = ts.createSourceFile(file.path, text, ts.ScriptTarget.Latest, true); const visit = (node: ts.Node): void => { const position = source.getLineAndCharacterOfPosition(node.getStart(source)); const line = position.line + 1; const evidence = lines[position.line] ?? ""; if (node.kind === ts.SyntaxKind.AnyKeyword) { results.push(finding( "explicit-any", "medium", file.path, line, "Explicit any weakens a type boundary", "The any type disables static checking for values flowing through this declaration.", "Use unknown with runtime validation, or define the smallest accurate type.", evidence, 0.95, )); } if (ts.isAsExpression(node) && ts.isAsExpression(node.expression) && node.expression.type.kind === ts.SyntaxKind.UnknownKeyword) { results.push(finding( "unsafe-type-assertion", "high", file.path, line, "Double assertion bypasses compatibility checking", "Casting through unknown can force a value into a target type without runtime validation.", "Validate the value or write an explicit conversion function.", evidence, 0.95, )); } ts.forEachChild(node, visit); }; visit(source); lines.forEach((line, index) => { if (/@ts-ignore|@ts-nocheck/.test(line)) { results.push(finding( "typescript-suppression", "high", file.path, index + 1, "TypeScript diagnostic suppression detected", "A suppression can conceal a real type mismatch.", "Fix the mismatch; where an expected error is intentional, document why it is expected.", line, 0.98, )); } }); } return results; } export function getCompilerFindings(): Finding[] { const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json"); if (!configPath) throw new Error("No tsconfig.json found in the current directory."); const config = ts.readConfigFile(configPath, ts.sys.readFile); if (config.error) { throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); } const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath)); const program = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options }); return ts.getPreEmitDiagnostics(program).map((diagnostic) => { const sourceFile = diagnostic.file; const start = diagnostic.start ?? 0; const location = sourceFile?.getLineAndCharacterOfPosition(start); const file = sourceFile ? path.relative(process.cwd(), sourceFile.fileName) : "tsconfig.json"; const message = ts.flatt
Comments
No comments yet. Start the discussion.