Implementing Credit Scoring Models in TypeScript

How to design and implement credit scoring models within a lending platform, covering scorecard construction, feature engineering, model serving, and monitoring in TypeScript.

technical10 min readBy Klivvr Engineering
Share:

Credit scoring is the quantitative backbone of every lending decision. It translates raw borrower data---payment history, outstanding debt, employment tenure, and dozens of other signals---into a numeric score that predicts the likelihood of default. While data science teams typically train scoring models in Python or R, the serving layer, integration logic, and operational infrastructure often live in TypeScript within the lending platform itself.

This article explores how to design a credit scoring subsystem in TypeScript that can host traditional scorecards, integrate with externally trained models, and provide the observability necessary for regulatory compliance and model governance.

Scorecard Fundamentals and Data Modeling

A scorecard is a points-based model where each borrower attribute contributes a weighted value to the final score. The simplicity of scorecards makes them popular in regulated lending environments because every decision can be explained point by point.

interface ScorecardAttribute {
  name: string;
  field: string;
  bins: ScorecardBin[];
}
 
interface ScorecardBin {
  label: string;
  condition: BinCondition;
  points: number;
}
 
type BinCondition =
  | { type: "range"; min: number; max: number }
  | { type: "exact"; value: string | number | boolean }
  | { type: "in"; values: (string | number)[] }
  | { type: "missing" };
 
interface Scorecard {
  id: string;
  name: string;
  version: number;
  baseScore: number;
  attributes: ScorecardAttribute[];
  cutoffs: {
    approve: number;
    review: number;
    decline: number;
  };
}

Each attribute maps to a field in the borrower's data profile. The bins define how continuous or categorical values are bucketed, and each bucket carries a point assignment derived from the weight-of-evidence analysis performed during model training.

class ScorecardEngine {
  constructor(private readonly scorecard: Scorecard) {}
 
  score(borrowerData: Record<string, unknown>): ScorecardResult {
    let totalPoints = this.scorecard.baseScore;
    const breakdown: AttributeScore[] = [];
 
    for (const attribute of this.scorecard.attributes) {
      const rawValue = borrowerData[attribute.field];
      const matchedBin = this.findMatchingBin(attribute.bins, rawValue);
 
      const points = matchedBin?.points ?? 0;
      totalPoints += points;
 
      breakdown.push({
        attributeName: attribute.name,
        field: attribute.field,
        rawValue,
        binLabel: matchedBin?.label ?? "unmatched",
        points,
      });
    }
 
    return {
      score: totalPoints,
      decision: this.mapToDecision(totalPoints),
      breakdown,
      scorecardVersion: this.scorecard.version,
      scoredAt: new Date(),
    };
  }
 
  private findMatchingBin(
    bins: ScorecardBin[],
    value: unknown
  ): ScorecardBin | undefined {
    if (value === null || value === undefined) {
      return bins.find((b) => b.condition.type === "missing");
    }
 
    return bins.find((bin) => {
      switch (bin.condition.type) {
        case "range":
          return (
            typeof value === "number" &&
            value >= bin.condition.min &&
            value < bin.condition.max
          );
        case "exact":
          return value === bin.condition.value;
        case "in":
          return bin.condition.values.includes(value as string | number);
        case "missing":
          return false;
      }
    });
  }
 
  private mapToDecision(score: number): CreditDecision {
    const { cutoffs } = this.scorecard;
    if (score >= cutoffs.approve) return "approve";
    if (score >= cutoffs.review) return "manual_review";
    return "decline";
  }
}
 
type CreditDecision = "approve" | "manual_review" | "decline";
 
interface AttributeScore {
  attributeName: string;
  field: string;
  rawValue: unknown;
  binLabel: string;
  points: number;
}
 
interface ScorecardResult {
  score: number;
  decision: CreditDecision;
  breakdown: AttributeScore[];
  scorecardVersion: number;
  scoredAt: Date;
}

The breakdown array is essential. Regulators require that lenders be able to explain why a particular decision was made, and the attribute-level point decomposition provides exactly that.

Feature Engineering Pipeline

Raw bureau data and application data rarely arrive in the shape that a scoring model expects. A feature engineering pipeline transforms raw inputs into the derived features that the scorecard or machine learning model was trained on.

interface FeatureDefinition {
  name: string;
  description: string;
  compute: (input: RawBorrowerData) => number | string | boolean | null;
}
 
interface RawBorrowerData {
  creditReport: CreditReport;
  application: ApplicationData;
  bankStatements?: BankStatementSummary;
  employmentHistory?: EmploymentRecord[];
}
 
const FEATURE_DEFINITIONS: FeatureDefinition[] = [
  {
    name: "debt_to_income_ratio",
    description: "Total monthly debt payments divided by gross monthly income",
    compute: (input) => {
      const monthlyDebt = input.creditReport.tradelines.reduce(
        (sum, t) => sum + (t.monthlyPayment ?? 0),
        0
      );
      const monthlyIncome = input.application.grossAnnualIncome / 12;
      if (monthlyIncome === 0) return null;
      return Math.round((monthlyDebt / monthlyIncome) * 100) / 100;
    },
  },
  {
    name: "credit_utilization",
    description: "Total revolving balance divided by total revolving limit",
    compute: (input) => {
      const revolving = input.creditReport.tradelines.filter(
        (t) => t.type === "revolving"
      );
      const totalBalance = revolving.reduce((s, t) => s + t.balance, 0);
      const totalLimit = revolving.reduce((s, t) => s + (t.creditLimit ?? 0), 0);
      if (totalLimit === 0) return null;
      return Math.round((totalBalance / totalLimit) * 100) / 100;
    },
  },
  {
    name: "months_at_current_employer",
    description: "Number of months employed at current employer",
    compute: (input) => {
      if (!input.employmentHistory || input.employmentHistory.length === 0) {
        return null;
      }
      const current = input.employmentHistory.find((e) => e.isCurrent);
      if (!current) return null;
      const now = new Date();
      const start = new Date(current.startDate);
      return Math.floor(
        (now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30)
      );
    },
  },
  {
    name: "recent_inquiry_count",
    description: "Number of hard inquiries in the last 6 months",
    compute: (input) => {
      const sixMonthsAgo = new Date();
      sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
      return input.creditReport.inquiries.filter(
        (i) => new Date(i.date) >= sixMonthsAgo
      ).length;
    },
  },
];
 
class FeatureEngineeringPipeline {
  constructor(private readonly definitions: FeatureDefinition[]) {}
 
  compute(input: RawBorrowerData): Record<string, unknown> {
    const features: Record<string, unknown> = {};
 
    for (const definition of this.definitions) {
      try {
        features[definition.name] = definition.compute(input);
      } catch (error) {
        features[definition.name] = null;
      }
    }
 
    return features;
  }
}

A practical tip: always wrap individual feature computations in a try-catch. A single malformed tradeline in a credit report should not prevent the entire feature vector from being computed. The scoring engine can handle missing features through its "missing" bin logic.

Model Versioning and Champion-Challenger Testing

Lending platforms evolve their scoring models over time. A robust serving layer must support multiple model versions running concurrently. The champion-challenger pattern routes a percentage of traffic to a new model (the challenger) while the incumbent (the champion) continues to make official decisions.

interface ModelDeployment {
  modelId: string;
  version: number;
  role: "champion" | "challenger";
  trafficPercentage: number;
  scorecard: Scorecard;
  deployedAt: Date;
  isActive: boolean;
}
 
class ModelRouter {
  constructor(private readonly deployments: ModelDeployment[]) {}
 
  selectModel(applicationId: string): ModelDeployment {
    const active = this.deployments.filter((d) => d.isActive);
    const champion = active.find((d) => d.role === "champion");
    const challenger = active.find((d) => d.role === "challenger");
 
    if (!champion) {
      throw new Error("No active champion model found");
    }
 
    if (!challenger) {
      return champion;
    }
 
    // Deterministic routing based on application ID
    const hash = this.simpleHash(applicationId);
    const bucket = hash % 100;
 
    return bucket < challenger.trafficPercentage ? challenger : champion;
  }
 
  async scoreWithShadow(
    applicationId: string,
    features: Record<string, unknown>
  ): Promise<{ primary: ScorecardResult; shadow?: ScorecardResult }> {
    const primaryModel = this.selectModel(applicationId);
    const primaryEngine = new ScorecardEngine(primaryModel.scorecard);
    const primaryResult = primaryEngine.score(features);
 
    // Always score with both models for monitoring
    const otherModel = this.deployments.find(
      (d) => d.isActive && d.modelId !== primaryModel.modelId
    );
 
    let shadowResult: ScorecardResult | undefined;
    if (otherModel) {
      const shadowEngine = new ScorecardEngine(otherModel.scorecard);
      shadowResult = shadowEngine.score(features);
    }
 
    return { primary: primaryResult, shadow: shadowResult };
  }
 
  private simpleHash(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = (hash << 5) - hash + char;
      hash |= 0;
    }
    return Math.abs(hash);
  }
}

Shadow scoring every application with both models generates the comparison data needed to validate the challenger before promoting it. Store both results in a scoring log for offline analysis.

Model Monitoring and Drift Detection

Deploying a scoring model is only the beginning. Over time, the distribution of borrower characteristics shifts, economic conditions change, and the model's predictive power degrades. Monitoring for this drift is essential.

interface ScoreDistributionBucket {
  rangeStart: number;
  rangeEnd: number;
  count: number;
  percentage: number;
}
 
interface ModelPerformanceMetrics {
  modelId: string;
  version: number;
  period: { start: Date; end: Date };
  totalScored: number;
  approvalRate: number;
  averageScore: number;
  scoreDistribution: ScoreDistributionBucket[];
  populationStabilityIndex: number;
}
 
class ModelMonitor {
  constructor(
    private readonly scoringLog: ScoringLogRepository,
    private readonly baselineDistribution: ScoreDistributionBucket[]
  ) {}
 
  async computePSI(
    modelId: string,
    periodStart: Date,
    periodEnd: Date
  ): Promise<number> {
    const recentScores = await this.scoringLog.getScoresForPeriod(
      modelId,
      periodStart,
      periodEnd
    );
 
    const observedDistribution = this.buildDistribution(recentScores);
    let psi = 0;
 
    for (let i = 0; i < this.baselineDistribution.length; i++) {
      const expected = this.baselineDistribution[i].percentage || 0.001;
      const observed = observedDistribution[i]?.percentage || 0.001;
 
      psi += (observed - expected) * Math.log(observed / expected);
    }
 
    return psi;
  }
 
  async checkForAlerts(modelId: string): Promise<MonitoringAlert[]> {
    const alerts: MonitoringAlert[] = [];
    const now = new Date();
    const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
 
    const psi = await this.computePSI(modelId, thirtyDaysAgo, now);
 
    if (psi > 0.25) {
      alerts.push({
        severity: "critical",
        message: `Population Stability Index of ${psi.toFixed(3)} exceeds critical threshold of 0.25`,
        metric: "psi",
        value: psi,
        threshold: 0.25,
        detectedAt: now,
      });
    } else if (psi > 0.1) {
      alerts.push({
        severity: "warning",
        message: `Population Stability Index of ${psi.toFixed(3)} exceeds warning threshold of 0.1`,
        metric: "psi",
        value: psi,
        threshold: 0.1,
        detectedAt: now,
      });
    }
 
    return alerts;
  }
 
  private buildDistribution(scores: number[]): ScoreDistributionBucket[] {
    // Build buckets matching baseline distribution ranges
    return this.baselineDistribution.map((baseline) => {
      const count = scores.filter(
        (s) => s >= baseline.rangeStart && s < baseline.rangeEnd
      ).length;
      return {
        rangeStart: baseline.rangeStart,
        rangeEnd: baseline.rangeEnd,
        count,
        percentage: scores.length > 0 ? count / scores.length : 0,
      };
    });
  }
}
 
interface MonitoringAlert {
  severity: "info" | "warning" | "critical";
  message: string;
  metric: string;
  value: number;
  threshold: number;
  detectedAt: Date;
}

The Population Stability Index (PSI) is a standard metric for detecting distribution shift. A PSI below 0.1 generally indicates a stable population, between 0.1 and 0.25 warrants investigation, and above 0.25 signals significant drift requiring model retraining or recalibration.

Adverse Action Reporting

When a loan application is declined or offered less favorable terms, regulators such as the CFPB in the United States require that the borrower be provided with specific reasons. The scoring breakdown makes this straightforward.

interface AdverseActionReason {
  code: string;
  description: string;
  points_lost: number;
}
 
class AdverseActionGenerator {
  private readonly reasonCodeMap: Record<string, string> = {
    debt_to_income_ratio: "Debt-to-income ratio is too high",
    credit_utilization: "Credit card utilization is too high",
    recent_inquiry_count: "Too many recent credit inquiries",
    months_at_current_employer: "Length of employment is insufficient",
    delinquency_count: "History of late payments on credit report",
    public_record_count: "Public records found on credit report",
  };
 
  generate(
    result: ScorecardResult,
    maxReasons: number = 4
  ): AdverseActionReason[] {
    const maxPossiblePoints = this.computeMaxPoints(result.breakdown);
 
    const reasons = result.breakdown
      .map((attr) => ({
        code: attr.field,
        description: this.reasonCodeMap[attr.field] ?? `Factor: ${attr.attributeName}`,
        points_lost: maxPossiblePoints[attr.field] - attr.points,
      }))
      .filter((r) => r.points_lost > 0)
      .sort((a, b) => b.points_lost - a.points_lost)
      .slice(0, maxReasons);
 
    return reasons;
  }
 
  private computeMaxPoints(
    breakdown: AttributeScore[]
  ): Record<string, number> {
    // In a real implementation, this would look up the max
    // possible points for each attribute from the scorecard definition
    const maxPoints: Record<string, number> = {};
    for (const attr of breakdown) {
      maxPoints[attr.field] = attr.points + 20; // Simplified placeholder
    }
    return maxPoints;
  }
}

A practical tip: always present adverse action reasons sorted by impact. The attributes where the borrower lost the most points relative to the maximum are the most meaningful reasons and should appear first.

Conclusion

A credit scoring subsystem in a lending platform must balance statistical rigor with operational concerns like versioning, monitoring, and regulatory compliance. TypeScript provides the type safety and expressiveness needed to model scorecards declaratively, build robust feature pipelines, and implement champion-challenger testing frameworks.

The key takeaways are: invest heavily in the feature engineering layer to insulate scoring logic from upstream data format changes; always store the full scoring breakdown for every decision; implement PSI-based drift monitoring from day one; and generate adverse action reasons automatically from the scorecard structure to satisfy regulatory requirements. These foundations enable a lending platform to iterate on its risk models confidently while maintaining the transparency that regulators and borrowers demand.

Related Articles

technical

Designing APIs for Lending Platforms

A comprehensive guide to designing robust, secure, and developer-friendly APIs for lending platforms, covering RESTful resource modeling, webhook architectures, idempotency, versioning, and partner integration patterns in TypeScript.

12 min read
business

Risk Management in Lending: Architecture and Strategy

A strategic guide to building a comprehensive risk management framework for lending platforms, covering credit risk, portfolio management, stress testing, concentration limits, and loss forecasting.

11 min read
business

Digital Lending Trends: What's Next for Fintech

A business-focused analysis of the trends shaping digital lending, including embedded finance, alternative data, real-time decisioning, open banking, and the evolution of lending-as-a-service platforms.

9 min read