Back to stories
<Frontend/>

Abstraction in JavaScript: What It Is and How to Implement

Share by

Abstraction in JavaScript: What It Is and How to Implement

Abstraction in code means hiding implementation details and exposing a simple, stable interface. Callers depend on what something does, not how it does it. In JavaScript you implement abstraction with functions, modules, classes, and types—no special syntax required. This post explains what abstraction is, how to implement it in JS, and real-life examples (storage, HTTP, auth, payments).


What abstraction is

Abstraction here means:

  • Hide details: The caller doesn’t need to know whether data lives in localStorage, a REST API, or a WebSocket. They call get(key) and set(key, value).
  • Expose a contract: A small, clear interface (e.g. “get”, “set”, “remove”) that doesn’t change when you swap the implementation.
  • Reduce coupling: Code that uses the abstraction doesn’t depend on fetch, XMLHttpRequest, or a specific backend URL. It depends only on the interface.

You’re not removing complexity—you’re moving it behind a boundary. The benefit is that callers stay simple and you can change or replace the implementation without touching them.

Concrete vs abstract:

  • Concrete: “Call fetch('/api/users'), then parse JSON, then check response.ok.” Tied to HTTP and your API shape.
  • Abstract: “Call userService.getAll().” You don’t care if that uses REST, GraphQL, or a mock. The interface is “get all users.”

Same idea in every language; in JavaScript you use functions, objects, and modules to draw that boundary.


How to implement abstraction in JavaScript

You don’t need classes or a type system to abstract. You can use plain functions and objects.

1. Functions that hide details

The simplest abstraction is a function: callers use the function name and arguments; they don’t see the body.

// Concrete: caller knows we use fetch and /api/users
const response = await fetch('/api/users')
if (!response.ok) throw new Error('Failed to fetch users')
const users = await response.json()

// Abstract: caller only knows "get users"
async function getUsers() {
  const response = await fetch('/api/users')
  if (!response.ok) throw new Error('Failed to fetch users')
  return response.json()
}
const users = await getUsers()

The interface is “getUsers() returns a promise of users.” How you get them (fetch, axios, mock) is hidden inside the function.

2. Objects / modules as interfaces

Group related operations into an object or a module. Callers use the object’s methods; the object owns the implementation.

// storage.js – abstraction over “key-value persistence”
const storage = {
  get(key) {
    try {
      const raw = localStorage.getItem(key)
      return raw ? JSON.parse(raw) : null
    } catch {
      return null
    }
  },
  set(key, value) {
    localStorage.setItem(key, JSON.stringify(value))
  },
  remove(key) {
    localStorage.removeItem(key)
  },
}

// Caller doesn’t know we use localStorage or JSON. They only use get/set/remove.
storage.set('user', { id: '1', name: 'Alice' })
const user = storage.get('user')

If you later switch to sessionStorage or an in-memory map for tests, you change only the object’s implementation; callers stay the same.

3. Dependency injection: pass the implementation

Callers receive the abstraction (e.g. a “storage” or “http client”) from the outside. The caller still depends only on the interface; the caller’s code doesn’t create fetch or localStorage.

function createUserService(http) {
  return {
    async getAll() {
      const data = await http.get('/api/users')
      return data
    },
    async getById(id) {
      const data = await http.get(`/api/users/${id}`)
      return data
    },
  }
}

// Production: real HTTP
const realHttp = {
  async get(url) {
    const res = await fetch(url)
    if (!res.ok) throw new Error(res.statusText)
    return res.json()
  },
}
const userService = createUserService(realHttp)

// Test: fake HTTP
const fakeHttp = {
  async get(url) {
    return [{ id: '1', name: 'Test User' }]
  },
}
const mockUserService = createUserService(fakeHttp)

createUserService doesn’t care whether http is real or fake. It only cares that http.get(url) returns data. That’s abstraction plus dependency injection.

4. Classes for stateful abstractions

When the abstraction holds state (e.g. a connection, a cache), a class can own that state and expose methods as the interface.

class StorageAdapter {
  constructor(backend = localStorage) {
    this.backend = backend
  }

  get(key) {
    try {
      const raw = this.backend.getItem(key)
      return raw ? JSON.parse(raw) : null
    } catch {
      return null
    }
  }

  set(key, value) {
    this.backend.setItem(key, JSON.stringify(value))
  }

  remove(key) {
    this.backend.removeItem(key)
  }
}

// Use default (localStorage)
const storage = new StorageAdapter()

// Test: in-memory backend
const memory = new Map()
const testStorage = new StorageAdapter({
  getItem: (k) => memory.get(k) ?? null,
  setItem: (k, v) => { memory.set(k, v) },
  removeItem: (k) => { memory.delete(k) },
})

The interface is “get, set, remove.” The backend (localStorage, sessionStorage, or a fake) is injected and hidden behind that interface.


Real-life example 1: Storage abstraction (localStorage, sessionStorage, API)

Different parts of the app might need “key-value” storage in different places: browser localStorage, sessionStorage, or even a remote cache. Abstract behind a single interface so callers don’t care where data lives.

// storage.abstract.js
function createStorage(backend) {
  return {
    get(key) {
      return backend.get(key)
    },
    set(key, value) {
      backend.set(key, value)
    },
    remove(key) {
      backend.remove(key)
    },
  }
}

// Backend: localStorage with JSON
const localStorageBackend = {
  get(key) {
    try {
      const raw = localStorage.getItem(key)
      return raw ? JSON.parse(raw) : null
    } catch {
      return null
    }
  },
  set(key, value) {
    localStorage.setItem(key, JSON.stringify(value))
  },
  remove(key) {
    localStorage.removeItem(key)
  },
}

// Backend: sessionStorage (same interface)
const sessionStorageBackend = {
  get(key) {
    try {
      const raw = sessionStorage.getItem(key)
      return raw ? JSON.parse(raw) : null
    } catch {
      return null
    }
  },
  set(key, value) {
    sessionStorage.setItem(key, JSON.stringify(value))
  },
  remove(key) {
    sessionStorage.removeItem(key)
  },
}

// Usage: same interface, different backend
const persistentStorage = createStorage(localStorageBackend)
const sessionStorageAdapter = createStorage(sessionStorageBackend)

persistentStorage.set('theme', 'dark')
sessionStorageAdapter.set('draft', { title: '', body: '' })

Callers use get / set / remove. They don’t know or care whether it’s localStorage or sessionStorage. When you add a “remote cache” backend (e.g. fetch to your API), you implement the same get/set/remove and pass it to createStorage; callers don’t change.


Real-life example 2: HTTP client abstraction

UI code shouldn’t depend on fetch, base URLs, or headers. Wrap that in an HTTP client abstraction; the rest of the app calls http.get(url) or api.getUsers().

// httpClient.js
function createHttpClient(config) {
  const { baseURL = '', getToken } = config

  async function request(method, path, body) {
    const url = path.startsWith('http') ? path : `${baseURL}${path}`
    const headers = { 'Content-Type': 'application/json' }
    if (getToken) {
      const token = await getToken()
      if (token) headers['Authorization'] = `Bearer ${token}`
    }
    const res = await fetch(url, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    })
    if (!res.ok) throw new Error(await res.text() || res.statusText)
    const text = await res.text()
    return text ? JSON.parse(text) : null
  }

  return {
    get(path) {
      return request('GET', path)
    },
    post(path, body) {
      return request('POST', path, body)
    },
    put(path, body) {
      return request('PUT', path, body)
    },
    delete(path) {
      return request('DELETE', path)
    },
  }
}

// Production
const http = createHttpClient({
  baseURL: 'https://api.example.com',
  getToken: () => localStorage.getItem('token'),
})

// Test or mock
const mockHttp = createHttpClient({})
// Or replace with a fake that returns static data

Services (e.g. userService) use http.get('/users') instead of raw fetch. Base URL, auth, and error handling live in one place; swapping to a different backend or mock only touches the creation of http.


Real-life example 3: Auth abstraction (login, logout, current user)

Auth can use cookies, tokens in localStorage, or a session on the server. Callers shouldn’t care. Expose a small interface: login, logout, get current user, and maybe “is authenticated.”

// auth.js
function createAuth(storage, api) {
  const USER_KEY = 'user'

  return {
    async login(credentials) {
      const { user, token } = await api.post('/auth/login', credentials)
      storage.set(USER_KEY, user)
      storage.set('token', token)
      return user
    },

    logout() {
      storage.remove(USER_KEY)
      storage.remove('token')
    },

    getCurrentUser() {
      return storage.get(USER_KEY)
    },

    getToken() {
      return storage.get('token')
    },

    isAuthenticated() {
      return !!this.getToken()
    },
  }
}

// Production: localStorage + real API
const auth = createAuth(persistentStorage, http)

// Test: in-memory storage + mock API
const mockApi = {
  async post(path, body) {
    return { user: { id: '1', email: body.email }, token: 'fake-token' }
  },
}
const testAuth = createAuth(testStorage, mockApi)

Components call auth.login(credentials), auth.logout(), auth.getCurrentUser(). They don’t know whether the token is in localStorage, a cookie, or a session. You can switch implementations (e.g. cookie-based auth) by changing how createAuth is built; the rest of the app stays the same.

Using the auth abstraction in Vue 3

In Vue 3 (Composition API), you keep the same abstraction and use it inside a composable. The component depends only on the composable’s interface, not on auth implementation details.

// composables/useAuth.ts
import { ref, computed, readonly } from 'vue'
import { createAuth } from '~/services/auth'   // the createAuth from above
import { persistentStorage } from '~/services/storage'
import { http } from '~/services/http'

const auth = createAuth(persistentStorage, http)

export function useAuth() {
  const user = ref(auth.getCurrentUser())
  const isAuthenticated = computed(() => auth.isAuthenticated())

  async function login(credentials: { email: string; password: string }) {
    const loggedInUser = await auth.login(credentials)
    user.value = loggedInUser
    return loggedInUser
  }

  function logout() {
    auth.logout()
    user.value = null
  }

  return {
    user: readonly(user),
    isAuthenticated,
    login,
    logout,
  }
}
<!-- components/LoginForm.vue -->
<script setup lang="ts">
const { user, isAuthenticated, login, logout } = useAuth()

const email = ref('')
const password = ref('')
const loading = ref(false)

async function onSubmit() {
  loading.value = true
  try {
    await login({ email: email.value, password: password.value })
    // navigate or emit
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <form v-if="!isAuthenticated" @submit.prevent="onSubmit">
    <!-- form fields -->
  </form>
  <div v-else>
    <p>Logged in as {{ user?.name }}</p>
    <button @click="logout">Log out</button>
  </div>
</template>

The component uses useAuth() and only cares about user, isAuthenticated, login, and logout. Where the token lives (localStorage, cookie, etc.) and how the API is called stay inside the auth abstraction and the composable that wraps it.


Real-life example 4: Payment abstraction (Stripe, PayPal, etc.)

Checkout shouldn’t depend on Stripe or PayPal directly. Abstract behind an interface like “create payment intent” or “charge”; the UI calls that, and you plug in the right provider.

// payment.js
function createPaymentService(provider) {
  return {
    async createPaymentIntent(amountCents, currency, metadata = {}) {
      return provider.createPaymentIntent(amountCents, currency, metadata)
    },
    async confirmPayment(clientSecret, paymentMethodId) {
      return provider.confirmPayment(clientSecret, paymentMethodId)
    },
  }
}

// Stripe implementation
const stripeProvider = {
  async createPaymentIntent(amountCents, currency, metadata) {
    const res = await fetch('/api/payments/create-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amountCents, currency, metadata }),
    })
    const data = await res.json()
    return data.clientSecret
  },
  async confirmPayment(clientSecret, paymentMethodId) {
    // Call Stripe SDK or your backend
    const res = await fetch('/api/payments/confirm', {
      method: 'POST',
      body: JSON.stringify({ clientSecret, paymentMethodId }),
    })
    return res.json()
  },
}

// PayPal implementation (same interface, different impl)
const paypalProvider = {
  async createPaymentIntent(amountCents, currency, metadata) {
    // PayPal-specific flow
    const order = await createPayPalOrder(amountCents, currency)
    return order.id
  },
  async confirmPayment(orderId, paymentMethodId) {
    return capturePayPalOrder(orderId)
  },
}

const paymentService = createPaymentService(stripeProvider)
// Or: createPaymentService(paypalProvider)

Checkout code uses paymentService.createPaymentIntent(...) and paymentService.confirmPayment(...). Which provider runs is decided when you create the service; the rest of the app is unchanged.


When to abstract and when not to

Abstract when:

  • Multiple implementations: You have or expect more than one way to do something (e.g. localStorage vs API, Stripe vs PayPal). One interface, swap implementations.
  • Testing: You need to fake I/O (storage, HTTP, auth). Injection + abstraction make mocks easy.
  • Volatile details: The “how” changes often (URLs, SDKs, storage keys). Hide that behind a stable interface so callers don’t break.

Don’t over-abstract when:

  • Single implementation: You only ever have one way to do it and no plans to change. A simple function or module is enough.
  • YAGNI (You Aren't Gonna Need It): You don’t have a second implementation yet. Add an abstraction when you actually need to swap (e.g. when you add a second payment provider).
  • Leaky abstraction: The interface keeps growing with “options” that are really implementation details. Prefer a small interface and move details behind it or into a different layer.

Rule of thumb: Abstract when you have a clear boundary (e.g. “all storage”, “all HTTP”, “all auth”) and at least one real need to swap or mock. Keep the interface small (few methods, clear names).


Summary

  • Abstraction = hide implementation details behind a simple, stable interface. Callers depend on what (get/set, login/logout), not how (localStorage, fetch, Stripe).
  • In JavaScript you implement it with: functions (hide logic), objects/modules (group operations), dependency injection (pass the implementation), and classes (stateful abstractions with an injected backend).
  • Real-life: Storage (localStorage vs sessionStorage vs API) behind get/set/remove; HTTP client (base URL, auth, errors) behind get/post/put/delete; Auth (token/cookie/session) behind login/logout/getCurrentUser; Payment (Stripe vs PayPal) behind createPaymentIntent/confirmPayment.
  • Vue 3: Wrap abstractions in composables (e.g. useAuth()) so components depend only on the composable’s interface; the abstraction (and its implementation) stays behind that boundary.
  • When: Abstract when you have multiple implementations or need to test with mocks; keep it concrete when you have a single, stable implementation. Prefer a small interface and add abstractions when you actually need to swap or fake behavior.