Create Service Layers with TypeScript
Learn how to organize business logic into typed service layers that keep your TypeScript codebase maintainable, testable, and free of scattered domain rules.
A TypeScript service layer is where your application's actual business rules live, separate from controllers that parse requests and repositories that read and write data. The logic that decides whether a customer qualifies for free shipping, calculates tax, or validates a coupon belongs in a dedicated service layer, not scattered across route handlers.
Without a service layer, business rules scatter across controllers, route handlers, and database queries. When the rules change, you hunt through the entire codebase. With a service layer, every business operation has one clear home.
TypeScript makes this safer because the types document exactly what each operation expects and returns. A function signature tells you everything you need to know without reading the implementation.
The Shape of a Service
A service in TypeScript is a module of plain functions. Each function takes domain types as input and returns domain types or result types as output. It never imports HTTP frameworks, database drivers, or request objects.
Here is the smallest useful service: a function that determines whether an order qualifies for free shipping:
type Money = { amount: number; currency: string };
type Order = { items: { price: Money }[]; shippingAddress: { country: string } };
function qualifiesForFreeShipping(order: Order): boolean {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price.amount,
0
);
return subtotal >= 50;
}This function has no side effects. It does not touch a database, read environment variables, or call an external API. You can test it with pure data and get a predictable result:
const order: Order = {
items: [{ price: { amount: 25, currency: "USD" } }, { price: { amount: 30, currency: "USD" } }],
shippingAddress: { country: "US" },
};
console.log(qualifiesForFreeShipping(order)); // trueThe output is true because the subtotal of 55 exceeds the threshold of 50. Pure functions like this are the ideal. When a service needs external data, pass the dependency as an argument instead of importing it directly.
Give Services Their Dependencies Explicitly
A service that checks inventory needs access to a database. Instead of importing a database client directly, accept it as a typed parameter. This keeps the service testable and decoupled from any specific storage technology.
First, define a lightweight interface that describes only what this service needs:
type InventoryRepo = {
getStock: (productId: string) => Promise<number>;
};Then the service function accepts that interface as an argument and uses it to query stock levels for each requested item:
async function checkAvailability(
items: { productId: string; quantity: number }[],
inventory: InventoryRepo
): Promise<{ available: boolean; shortages: { productId: string; shortBy: number }[] }> {
const shortages: { productId: string; shortBy: number }[] = [];
for (const item of items) {
const stock = await inventory.getStock(item.productId);
if (stock < item.quantity) {
shortages.push({ productId: item.productId, shortBy: item.quantity - stock });
}
}
return { available: shortages.length === 0, shortages };
}The function loops through each requested item, queries the stock level, and records any shortage where the available quantity is less than what was requested. The return value tells the caller both whether the order can be fulfilled and exactly which products are short.
The real implementation might query PostgreSQL, but the service does not care. In tests, you pass a plain object that returns hardcoded values:
const mockInventory: InventoryRepo = {
getStock: async (id) => (id === "prod_1" ? 10 : 0),
};
const result = await checkAvailability(
[{ productId: "prod_1", quantity: 5 }],
mockInventory
);
console.log(result.available); // trueThe InventoryRepo type acts as a contract. Any object that satisfies it works, whether real or mock. This pattern is sometimes called the repository pattern or dependency injection via function arguments.
Use Result Types Instead of Throwing
Services should not throw errors for expected failure cases. A missing order or an expired coupon is normal, not exceptional. Throwing breaks the type signature, since the caller cannot see from the return type that failure is possible.
A Result type makes failure explicit in the return type instead:
type Result<T, E> =
| { success: true; value: T }
| { success: false; error: E };
type ApplyCouponError = "OrderNotFound" | "InvalidCoupon";The Result type makes every possible outcome visible in the function signature. The caller is forced by the compiler to handle both the success and failure branches, and TypeScript narrows the error type inside the else block:
declare function applyCouponToOrder(
orderId: string,
couponCode: string
): Promise<Result<{ total: number }, ApplyCouponError>>;
const result = await applyCouponToOrder("ord_1", "SAVE10");
if (result.success) {
console.log(`New total: ${result.value.total}`);
} else {
console.error(`Failed: ${result.error}`);
}The compiler narrows the error type inside the else branch. If you add a new error variant later, every caller that switches on the result must handle it.
For more on error handling patterns, see result pattern in TypeScript.
Separate Orchestration from Domain Logic
A common mistake is putting orchestration logic inside a service function. Orchestration means calling multiple services in sequence and deciding what to do with their results. That belongs in a dedicated layer, often called an application service or use case.
The domain service contains pure business rules. The application service wires everything together. Here is the domain function, which just does math:
type PricedOrder = { id: string; subtotal: number; discount?: number; total?: number };
type Coupon = { type: "percentage" | "fixed"; value: number; expiresAt: Date };
function calculateDiscount(order: PricedOrder, coupon: Coupon): number {
if (coupon.type === "percentage") {
return order.subtotal * (coupon.value / 100);
}
return Math.min(coupon.value, order.subtotal);
}This domain function has no side effects and no dependencies. You can test it by passing in an order and a coupon and checking the returned number.
The application service needs its own repository interfaces, the same lightweight pattern used earlier for inventory:
type OrderRepo = {
findById: (id: string) => Promise<PricedOrder | undefined>;
save: (order: PricedOrder) => Promise<void>;
};
type CouponRepo = { findByCode: (code: string) => Promise<Coupon | undefined> };With those in place, the orchestrator can find the order, find the coupon, validate, calculate, and save. It reuses the Result and ApplyCouponError types from the previous section:
async function applyCouponToOrder(
orderId: string,
couponCode: string,
deps: { orders: OrderRepo; coupons: CouponRepo }
): Promise<Result<PricedOrder, ApplyCouponError>> {
const order = await deps.orders.findById(orderId);
if (!order) return { success: false, error: "OrderNotFound" };
const coupon = await deps.coupons.findByCode(couponCode);
if (!coupon || coupon.expiresAt < new Date()) {
return { success: false, error: "InvalidCoupon" };
}
const discount = calculateDiscount(order, coupon);
const updatedOrder = { ...order, discount, total: order.subtotal - discount };
await deps.orders.save(updatedOrder);
return { success: true, value: updatedOrder };
}The domain function is pure and testable in isolation. The application service handles the sequence without knowing about HTTP requests or response formats.
What a Service File Should Look Like
A well-organized service module separates types, dependency interfaces, and orchestration into clear files:
| File | Contains |
|---|---|
types.ts | Shape definitions only, no runtime code |
dependencies.ts | Interfaces describing what the service needs, such as ShippingRateRepo |
orchestration.ts | Functions that call repositories and combine results |
Only the orchestration file calls the repository. It wires the dependency to the filtering logic and fetches the rates for the order's shipping address:
type ShippingRate = { method: string; cost: Money; estimatedDays: number };
type ShippingRateRepo = { getRates: (address: { country: string }) => Promise<ShippingRate[]> };
async function getShippingOptions(
order: Order,
deps: { shippingRates: ShippingRateRepo }
): Promise<ShippingRate[]> {
const rates = await deps.shippingRates.getRates(order.shippingAddress);
return rates.filter((r) => qualifiesForFreeShipping(order) ? r.cost.amount === 0 : true);
}This separation matters. The pure function is unit-testable with no mocks.
The orchestrator is integration-testable with a real or fake repository. Neither imports Express, Next.js, Prisma, or any framework.
For more on organizing types across a project, see share types across a TypeScript codebase.
Common Mistakes
Importing request objects into a service. If a service function takes a raw request object, it is tied to Express forever. Take only the data it needs: a customer ID string, not a request params object.
Putting SQL queries in a service. Services should call repository functions, not write queries. If a service contains a raw SELECT statement, a database schema change forces service changes.
Using class-based services for stateless logic. A class with no instance state and one public method is just a function wearing a costume. Export the function directly.
Returning HTTP-specific types from services. A service that returns status codes and response bodies has absorbed controller responsibilities. Return domain types and let the controller build the response.
For more on typing the boundary between services and external systems, see type API contracts in TypeScript.
Rune AI
Key Insights
- Service layers separate business logic from HTTP controllers and database access.
- Stateless functions that take domain types and return domain types are the simplest and most testable service pattern.
- Dependencies like database clients are passed as function arguments, not hidden in constructors or imports.
- Never import Express types, database types, or request objects inside a service module.
- A service function should not know whether it was called from a REST endpoint, a GraphQL resolver, or a CLI command.
Frequently Asked Questions
What is a service layer in a TypeScript application?
Should TypeScript services be classes or plain functions?
Conclusion
A typed service layer gives every piece of business logic a clear home. Controllers handle HTTP, repositories handle storage, and services handle the actual rules. When your service functions take domain types as input and return domain types or result types as output, the compiler verifies that your business logic is wired correctly at every call site.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.