Cleaner Code With Declarative Programming
Discover declarative programming, with TypeScript examples, principles, and tips for clean, resilient, and testable code

In software development, understanding the differences between declarative and imperative programming provides a foundation for creating clean, maintainable, and resilient codebases. Declarative programming principles transform how we approach coding, testing, and system design, enabling more flexible architectures. This article breaks down the key differences and shows how declarative programming aligns with architectural principles like Separation of Concerns (SoC) and the Single Responsibility Principle (SRP).
An Overview
Imperative Programming involves detailing the exact steps the code should take to achieve a result. It resembles following a recipe that specifies every action. Imperative programming uses loops, conditionals, and explicit step-by-step instructions to get the job done. For instance, if we want to filter a list of numbers to get only even numbers, the imperative approach would look something like this:
// Imperative style in TypeScript
const numbers: number[] = [1, 2, 3, 4, 5, 6];
const evenNumbers: number[] = [];
for (const number of numbers) {
if (number % 2 === 0) {
evenNumbers.push(number);
}
}Declarative Programming focuses on what needs to happen rather than on how to do it. With a declarative approach, we specify the desired outcome, and the language or framework manages the details. Here's the same example in a more declarative style:
// Declarative style in TypeScript
const numbers: number[] = [1, 2, 3, 4, 5, 6];
const isEvent = (num: number) => num % 2 === 0;
const evenNumbers: number[] = numbers.filter(isEven);By focusing on what, declarative programming simplifies the codebase, making it more readable and less prone to error.
Separation of Concerns
Declarative programming offers a powerful tool for applying Separation of Concerns (SoC) at multiple levels of an application:
System Level: Different systems in an architecture (e.g., microservices) handle specific functions, minimizing dependencies and creating clear boundaries.
Component Level: Within a system, components should function independently based on purpose. For instance, keep application logic separate from infrastructure code and domain concerns.
Code Level: Even within a function, declarative programming separates the what from the how. We can, for instance, specify a predicate that must hold true to return a value without explicitly laying out each step.
Declarative programming aligns perfectly with the Single Responsibility Principle (SRP) as well, allowing each function or module to focus on one responsibility without managing unrelated logic.
Managing Change with Declarative Programming
Software constantly evolves, and managing changes is essential for building sustainable systems. Declarative programming helps manage changes by isolating the what from the how.
Low-Volatility in What vs. High-Volatility in How
Often, what needs to happen in an application remains more stable than how it should happen. For example, a business rule might specify that "all new users must be verified," a rule that rarely changes. However, the mechanism (how) for verifying users (e.g., checking identity documents, sending an OTP) might change more frequently. Declarative programming allows us to modify how without impacting what, reducing the risk of unintended side effects.
Imagine a matrix where rows represent the what (e.g., business rules or system outputs) and columns represent the how (e.g., infrastructure changes or algorithms). Declarative code keeps these dimensions modular, so changes in one area don't cascade into others, keeping the codebase clean and adaptable.
Declarative Programming Supports Test-Driven Development (TDD)
Test-driven development (TDD) thrives with declarative code because the tests focus on outcomes rather than specific steps. Declarative programming enables us to test for what should happen, such as "a user should be verified after signup," without coupling the tests to how the implementation details achieve this.
Perhaps we're building a feature to calculate the total price of a shopping cart. Using TDD and declarative programming together, we start by writing tests that specify the expected total for a set of items in the cart. Declarative programming keeps these tests resilient to refactoring because the tests remain focused on what (e.g., the final total) rather than the specific steps to achieve it.
// TypeScript interface and function for declarative code example
interface CartItem {
cost: number;
qty: number;
}
function calculateTotal(cartItems: CartItem[]): number {
return cartItems.reduce((total, item) => total + item.cost * item.qty, 0);
}
// Test case
const cartItems: CartItem[] = [
{ price: 10, quantity: 2 },
{ price: 15, quantity: 1 }
];
assert(calculateTotal(cartItems) === 35, "Total should be 35");With this approach, changes to how the price is calculated (like applying a discount) won't affect the core test, keeping TDD efficient and allowing quick refactoring.
Refactoring Imperative Code into Declarative Code
Let's look at a practical example of refactoring imperative code into a declarative style. Consider a scenario where we need to validate a list of transactions, applying business rules to filter out invalid ones.
Imperative Approach:
// Filtering transactions imperatively
const validTransactions: Transaction[] = [];
for (const transaction of transactions) {
if (transaction.isValid()) {
validTransactions.push(transaction);
}
}Declarative Approach:
// Filtering transactions declaratively
const isValid = (transaction: Transaction) => transaction.isValid();
const validTransactions: Transaction[] = transactions.filter(isValid);Both approaches achieve the same goal, but the declarative style simplifies the code and reduces errors. Declarative refactoring, like in this example, allows us to build a cleaner, more understandable, and more maintainable codebase.
Abstraction and Declarative Programming
A common misperception is that declarative code always runs slower because of its abstraction layer. While abstraction can introduce overhead, most modern languages and frameworks optimize declarative constructs, maintaining performance.
Some developers abstract how things happen too much, making debugging harder. Balance declarative and imperative styles; certain lower-level logic may still need imperative control for optimal performance or clarity.
Modern Frameworks and Tools
Declarative programming isn't just a coding style; it's the foundation of modern frameworks and infrastructure tools.
Libraries like React and Vue embrace declarative principles by allowing developers to specify the desired UI state, with the framework handling the implementation details.
Declarative programming drives IaC tools like Terraform and Ansible. By defining the desired state of infrastructure, DevOps engineers can focus on what resources they need, while the tool manages how to provision them.
Adopting Declarative Practices
Here are actionable tips to integrate declarative programming into your daily work:
Leverage higher-order functions like map, filter, and reduce to simplify common tasks without detailing each step explicitly.
Isolate logic from dependencies and implementation details. This aligns with SRP and SoC, making the codebase more adaptable.
When designing new features, consider how declarative code can simplify testing, focusing on outcomes rather than implementation.
Declarative programming promotes clean, maintainable, and testable code, allowing developers to separate what needs to happen from how it happens. By adopting declarative principles, we create code that's easier to refactor, better aligned with architectural best practices, and adaptable to change—an invaluable approach in today's dynamic software landscape.
For paid subscribers, CodeCraft Dispatch offers exclusive resources, such as common declarative patterns, advanced guides on test-driven declarative architecture, and code examples to streamline your refactoring efforts.


