js

Build a Type-Safe Payment Gateway Abstraction in TypeScript

Learn how to design a type-safe payment gateway abstraction in TypeScript to avoid vendor lock-in, simplify integrations, and scale confidently.

Build a Type-Safe Payment Gateway Abstraction in TypeScript

I’ve been thinking about payments lately. Not just as a transaction, but as a critical, fragile part of any application. If it breaks, trust breaks. I’ve seen too many codebases become prisoners to a single payment provider’s SDK. What happens when you need to switch, or add another? You often face a massive rewrite.

This isn’t just theoretical. Perhaps your business is expanding to a new country where your primary provider isn’t the best choice. Maybe you need a backup for reliability, or you want to test different fee structures. The idea of being locked in scared me. So, I set out to design a better way—a single, type-safe system that could speak to multiple payment gateways without the chaos.

Why should you care? Because your future self will thank you for the flexibility. This approach turns a potential nightmare into a manageable, even elegant, part of your system. Let’s build something robust together.

Think of a payment gateway like a language. Stripe speaks one dialect, PayPal another, Braintree yet another. Our goal is to build a universal translator. This translator needs a strict rulebook, a contract, that every provider must follow. In code, we call this an interface.

How do we ensure a Stripe charge and a PayPal order can be handled by the same code? We define the common ground. Every payment operation, at its core, needs similar data: an amount, a currency, a customer identifier. More importantly, every operation returns a result that must fit a predictable shape, whether it succeeds or fails.

This is where TypeScript becomes your best friend. Instead of using generic objects or hoping the data matches, we can define the exact structure of a payment result. We use something called a discriminated union. It’s a fancy term for a simple idea: a result is either a success or a failure, and TypeScript will know which one you’re dealing with.

Here’s what that core type might look like.

type PaymentResult =
  | {
      success: true;
      provider: 'stripe' | 'paypal' | 'braintree';
      transactionId: string;
      status: 'succeeded' | 'pending';
      amount: number;
    }
  | {
      success: false;
      provider: 'stripe' | 'paypal' | 'braintree';
      errorCode: string;
      retryable: boolean;
    };

See the success field? It’s the discriminator. If success is true, TypeScript knows the object will have a transactionId and status. If it’s false, it knows to look for errorCode. This eliminates a whole category of bugs. You can’t accidentally access a transactionId on a failed payment.

With our contract defined, we need concrete workers. This is where the Strategy pattern fits perfectly. Each payment provider (Stripe, PayPal, Braintree) becomes a separate “strategy” class. Each class implements the same central interface. Your main application code doesn’t call Stripe directly; it calls this interface. The specific provider is just a detail.

What does this interface actually contain? Let’s outline the essential actions: charging a card, handling a refund, and maybe saving a payment method for later use. Each method would accept a standardized request object and promise to return our standard PaymentResult.

Let’s sketch a provider’s duty for taking a payment.

interface PaymentProvider {
  charge(request: ChargeRequest): Promise<PaymentResult>;
  refund(transactionId: string, amount: number): Promise<RefundResult>;
  // ... other methods
}

// Example ChargeRequest
const chargeRequest = {
  idempotencyKey: 'order_123_attempt_1',
  amount: 2500, // $25.00 in cents
  currency: 'USD',
  customerId: 'cust_abc123',
  sourceToken: 'tok_visa_xyz' // Token from frontend
};

Notice the idempotencyKey. This is crucial for safety. If a network connection drops, you can retry the same request with the same key, and the provider will prevent duplicate charges. It’s a must-have for any serious payment system.

Now, imagine your user is in Europe and you want to use Stripe, but for a user in a different region, you need to route to PayPal. How do you decide? You create a simple router. This could be based on the customer’s country, the transaction amount, or even a feature flag. The router’s only job is to pick the correct provider strategy from your toolbox.

The code that uses the payment becomes beautifully simple. It asks the router for a provider and executes the charge. It doesn’t care about the underlying API calls, the specific error formats, or the authentication headers. That complexity is sealed inside each provider class.

But what about the money? You have to know what happened. Payment gateways send events—asynchronous messages about successful charges, failed payments, or disputes. These are called webhooks. Each provider sends them in a different format. Your system needs one internal format.

You build a webhook orchestrator. It has a small endpoint for each provider (/webhooks/stripe, /webhooks/paypal). When a request comes in, the orchestrator verifies it’s legitimate (using a secret key), then translates the provider-specific event into your own normalized event. This normalized event then triggers the same business logic in your app, regardless of who sent it.

Does this seem like extra work? Initially, yes. But the payoff is immense. Adding a fourth provider in the future is just about creating a new class that speaks the interface and a new webhook adapter. Your core business logic remains untouched. You’ve built a system that welcomes change instead of fearing it.

Let’s look at a tiny, concrete piece: the beginning of a Stripe provider class.

class StripeProvider implements PaymentProvider {
  private stripe: Stripe;

  constructor(apiKey: string) {
    this.stripe = new Stripe(apiKey);
  }

  async charge(request: ChargeRequest): Promise<PaymentResult> {
    try {
      const paymentIntent = await this.stripe.paymentIntents.create({
        amount: request.amount,
        currency: request.currency,
        customer: request.customerId,
        payment_method: request.sourceToken,
        idempotency_key: request.idempotencyKey,
      });

      return {
        success: true,
        provider: 'stripe',
        transactionId: yourInternalId,
        status: paymentIntent.status === 'succeeded' ? 'succeeded' : 'pending',
        amount: request.amount,
      };
    } catch (error) {
      // Map Stripe's specific error to your standard error format
      return {
        success: false,
        provider: 'stripe',
        errorCode: error.code || 'unknown',
        retryable: this.isErrorRetryable(error),
      };
    }
  }
}

The key is in the mapping. Inside the try block, we adapt Stripe’s paymentIntent to our PaymentResult. Inside the catch, we translate Stripe’s error into our standard error shape. The public method signature never changes.

Testing this becomes a clearer process too. You can test the core payment logic using a mock provider that just returns predictable results. You then test each provider implementation in isolation against sandbox API keys. This separation makes your tests faster and more reliable.

Building this abstraction is an investment in the stability and adaptability of your business logic. It turns payment processing from a tangled web of provider-specific code into a clean, mechanical procedure. You gain control, reduce risk, and open doors to global opportunities.

I hope this walkthrough gives you a solid foundation to start building your own payment gateway abstraction. The initial effort is worth the long-term peace of mind and flexibility. What part of your current system would benefit most from this kind of design?

If you found this guide helpful, please share it with a fellow developer who might be wrestling with similar problems. Have you implemented something like this before? I’d love to hear about your experiences or answer any questions in the comments below. Let’s keep the conversation going and build more resilient systems together.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: TypeScript payment gateway, payment abstraction, vendor lock-in, Strategy pattern, webhook normalization



Similar Posts
Blog Image
Complete Guide to Next.js and Prisma Integration for Type-Safe Database Operations in 2024

Learn to integrate Next.js with Prisma for type-safe database operations. Build full-stack apps with auto-generated types and seamless data consistency.

Blog Image
How to Use Bull and Redis to Build Fast, Reliable Background Jobs in Node.js

Learn how to improve app performance and user experience by offloading tasks with Bull queues and Redis in Node.js.

Blog Image
Complete Multi-Tenant SaaS Architecture: NestJS, Prisma, PostgreSQL Production Guide with Schema Isolation

Build production-ready multi-tenant SaaS with NestJS, Prisma & PostgreSQL. Learn schema isolation, dynamic connections, auth guards & migrations.

Blog Image
Complete Guide to Integrating Prisma with GraphQL for Type-Safe APIs in 2024

Learn how to integrate Prisma with GraphQL for type-safe APIs, efficient database operations, and seamless full-stack development. Build modern applications today.

Blog Image
Complete Guide to Next.js and Prisma Integration for Full-Stack TypeScript Applications

Learn to integrate Next.js with Prisma ORM for type-safe full-stack applications. Step-by-step guide with schema setup, API routes, and best practices.

Blog Image
Complete Guide: Building Resilient Event-Driven Microservices with Node.js TypeScript and Apache Kafka

Learn to build resilient event-driven microservices with Node.js, TypeScript & Kafka. Master producers, consumers, error handling & monitoring patterns.