The Aristotelian Approach to Writing Good Programs: Logic as the Foundation of Code
The Aristotelian Approach to Writing Good Programs: Logic as the Foundation of Code
Frameworks turn over every couple of years. The logic underneath good code doesn't. Aristotle worked these rules out 2,400 years ago, and they map almost directly onto how I write software that doesn't fall apart.
When code keeps breaking, the problem is almost never the framework. It's muddy thinking. The framework is just where the muddy thinking finally surfaces as a stack trace.
I figured this out the slow way, debugging other people's code and a fair pile of my own. The reliable programs I kept running into weren't reliable because of some clever library. They were reliable because the person who wrote them was disciplined about what a thing is, what it can and can't be, and whether the next operation is even allowed to run. That's not a JavaScript skill. That's logic, and we've had the rulebook for a very long time.
This isn't philosophy for its own sake. It's a working methodology that has produced more debuggable, maintainable code for me than any framework-specific "best practice" I've adopted and later thrown out.
The Three Laws of Thought, Applied to Code
Aristotle's three laws of thought are Identity, Non-Contradiction, and the Excluded Middle. People misremember the third one, so I'll be precise: it's the Excluded Middle, not the Principle of Sufficient Reason. Sufficient Reason is Leibniz, and it shows up later in this article as a corollary, properly credited. Aristotle's three map cleanly onto the three problems we fight every day: naming, state, and ambiguity.
1. The Law of Identity: Name Things What They Actually Are
Aristotle: "A is A." Everything is identical to itself.
In code: a variable should be exactly what its name claims, nothing more, nothing less.
Obvious until you read this:
// What is userData, actually?
const userData = await fetchUser(id);
// Is it user data? An HTTP response? An error object?
// The name is lying about the identity.
// Better:
const userApiResponse = await fetchUser(id);
const userData = userApiResponse.data;
const userError = userApiResponse.error;
When I see a variable called userEmail, it should hold a user's email address. Not sometimes an email, sometimes null, sometimes an error object, sometimes a boolean telling me whether the email was validated. A name that means four things means nothing.
In practice:
- Name variables for what they contain, not what they might contain.
- Reach for specific types over generic ones:
EmailAddress, notstring. - A function named
calculateTotalPricecalculates and returns a total price. It does not also update the UI and fire an analytics event.
The name is a contract. Honor it.
2. The Law of Non-Contradiction: A Value Can't Be Two Opposite Things at Once
Aristotle: nothing can be both A and not-A at the same time.
In code: a variable cannot simultaneously be valid data and invalid data. It cannot be both a User object and an error condition.
This is where most bugs are born. You write the happy path assuming a variable holds valid data, but somewhere upstream it quietly became something else, and now price * 1.08 is NaN.
// Violates non-contradiction
function calculateTotal(price) {
// price could be a number, string, null, undefined, or object
return price * 1.08; // NaN, weird coercion, or a crash
}
// Honors it
function calculateTotal(price: number) {
if (typeof price !== 'number' || price < 0) {
throw new Error('Price must be a positive number');
}
// From here down, price is exactly what we say it is
return price * 1.08;
}
In practice:
- Use a type system that enforces these guarantees.
- Validate at the boundary, then trust inside the boundary.
- Handle errors explicitly so they never contaminate a valid data flow.
Think of it like a clean room: you decontaminate at the door so nobody has to wear a hazmat suit inside.
3. The Law of the Excluded Middle: A Value Is Either Valid or It Isn't. There Is No Third State.
Aristotle: everything must either be A or not-A. There's no in-between.
In code: by the time a value reaches your logic, it's either valid or it's rejected. You don't allow a fuzzy middle where it's "probably fine." You force a decision at the boundary.
Non-Contradiction says a value can't be both things at once. The Excluded Middle goes one step further: it can't sit in some undecided third state either. This is the law that kills the maybe-it's-an-email-maybe-it's-null-maybe-it's-a-flag variable. You don't pass ambiguity downstream and hope. You resolve it to one side of the line right now.
// Leaves the middle open: the value drifts through in an undecided state
function getEmail(input: unknown): string {
return input as string; // maybe a string, maybe null, maybe garbage
}
// Closes the middle: it comes out the far side as a real EmailAddress, or not at all
function toEmailAddress(input: unknown): EmailAddress {
if (typeof input !== 'string' || !input.includes('@')) {
throw new ValidationError('Not a valid email address');
}
return input as EmailAddress;
}
This is also the argument for making illegal states unrepresentable. If a value can only legally be one of two things, your types and your validation should make the third, fuzzy option impossible to even express. Decide at the door. Don't let "undecided" be a state your program can hold.
A Corollary (Credit to Leibniz): Your Logic Should Read Like Its Own Proof
Here's where I borrow from Leibniz instead of Aristotle, because credit matters. The Principle of Sufficient Reason says nothing exists without a reason that can be discovered and understood. Applied to code: your logic should be provable to any competent developer who reads it, not just to you at 2 AM on your sixth coffee.
// Insufficient reason: only the author knows what this does
const result = data.filter(x => x.type === 'active')
.map(x => ({ ...x, score: x.points * 2.5 + (x.bonus || 0) }))
.filter(x => x.score > threshold)
.sort((a, b) => b.score - a.score)[0];
// Sufficient reason: each step states its intent
const activeItems = data.filter(item => item.type === 'active');
const scoredItems = activeItems.map(item => ({
score: calculateItemScore(item),
item,
}));
const qualifyingItems = scoredItems.filter(({ score }) => score > threshold);
const topItem = findHighestScoringItem(qualifyingItems);
One caveat, because I don't want this turned into dogma: this isn't a sermon about "clean code" and shredding everything into micro-functions. The point is to express the developer's explicit intent. Sometimes named intermediate variables do that. Sometimes a single well-placed comment does the same job without the ceremony. Use judgment. The goal is legibility of intent, not a function count.
The Guiding Principle: Do Not Try Something That Will Fail
This sits underneath all of it, and it's the line I hold hardest. If you can predict that an operation might fail, validate the preconditions before you attempt it. Don't try it and clean up the wreckage afterward.
The one exception is network calls and other interactions with external systems. Failure is inherent to the medium there. The network is allowed to fail, so you handle that failure as a normal, expected case. Everything that isn't dependent on the outside world can be solved with pure logic, and pure logic doesn't need a try/catch around it.
// Trying something that will fail
function divide(a, b) {
return a / b; // Infinity or NaN for bad inputs
}
// Refusing to try something that will fail
function divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Division requires numeric inputs');
}
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
This flips error handling from reactive debugging to proactive validation. You're not fixing problems after they happen. You're refusing to let them happen.
The Four-Block Structure
Here's how I put all of this into practice. Every substantial piece of logic, a function or a class method, follows the same four blocks. The shape is always the same, which is exactly the point: predictable structure is what makes code skimmable.
Block 1: Declaration (Identity and Purpose)
Establish what this function is and what it operates on. Gather what you'll need, declare it with honest names and types, and make the contract explicit.
function processPaymentTransaction(
customerAccount: CustomerAccount,
paymentMethod: PaymentMethod,
amount: MonetaryAmount,
orderDetails: OrderDetails
): PaymentResult {
const transactionId = generateTransactionId();
const timestamp = getCurrentTimestamp();
const currency = amount.currency;
const paymentProcessor = getPaymentProcessor(paymentMethod.type);
const fraudDetectionService = getFraudDetectionService();
This block answers: what is this function, and what does it need to do its job?
Block 2: Validation (Do Not Try Something That Will Fail)
Prove every precondition before going further. This is the "do not try something that will fail" principle made concrete.
if (!customerAccount) {
throw new CustomerAccountNotFoundError();
}
if (customerAccount.status !== 'active') {
throw new CustomerAccountInactiveError(customerAccount.customerId);
}
if (!paymentMethod.isValid()) {
throw new InvalidPaymentMethodError(paymentMethod);
}
if (paymentMethod.isExpired()) {
throw new ExpiredPaymentMethodError(paymentMethod);
}
if (amount.value <= 0) {
throw new InvalidAmountError('Amount must be positive');
}
if (amount.value > paymentMethod.creditLimit) {
throw new CreditLimitExceededError(amount, paymentMethod.creditLimit);
}
const fraudRisk = await fraudDetectionService.assessRisk(customerAccount, amount, paymentMethod);
if (fraudRisk.level === 'high') {
throw new FraudRiskTooHighError(fraudRisk);
}
This block answers: are we certain this operation can succeed?
Block 3: Process and Transact (Execution)
Because Block 2 already proved the preconditions, this block does the actual work with no defensive noise. No second-guessing the inputs. They're clean. You decontaminated them at the door.
const processingFee = paymentProcessor.calculateFee(amount);
const totalAmount = amount.add(processingFee);
const transaction = new PaymentTransaction({
id: transactionId,
customerId: customerAccount.customerId,
paymentMethod,
amount: totalAmount,
orderDetails,
timestamp,
status: 'processing',
});
const paymentResult = await paymentProcessor.charge(
paymentMethod,
totalAmount,
transactionId
);
transaction.updateStatus(paymentResult.status);
transaction.setExternalTransactionId(paymentResult.externalId);
This block answers: what exactly happens when every condition is met?
Block 4: Commit and Respond (Intent)
Persist the changes and return something meaningful. This is where you make the outcome legible to the caller.
await customerAccount.recordTransaction(transaction);
if (paymentResult.status === 'successful') {
await orderService.markAsPaid(orderDetails.id);
await notificationService.sendPaymentConfirmation(customerAccount, transaction);
return {
status: 'success',
transactionId,
amount: totalAmount,
confirmationCode: paymentResult.confirmationCode,
};
}
await orderService.markAsPaymentFailed(orderDetails.id);
return {
status: 'failed',
transactionId,
reason: paymentResult.failureReason,
retryAllowed: paymentResult.retryable,
};
}
This block answers: what should the world look like after this runs, and what should the caller know?
Error Handling: Validate, Then Execute With Confidence
The difference from typical exception-based code is the whole point. The traditional approach tries something and catches the exception when it blows up. The approach here validates the preconditions and then executes knowing it'll work.
// Reactive: try it, then figure out what went wrong and maybe roll back
async function transferMoney(fromAccount, toAccount, amount) {
try {
fromAccount.withdraw(amount);
toAccount.deposit(amount);
await accountRepository.saveAll([fromAccount, toAccount]);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
// Proactive: prove it can succeed, then do it
async function transferMoney(fromAccount, toAccount, amount) {
if (fromAccount.balance < amount) {
throw new InsufficientFundsError(fromAccount.balance, amount);
}
if (toAccount.status !== 'active') {
throw new AccountInactiveError(toAccount.id);
}
fromAccount.withdraw(amount);
toAccount.deposit(amount);
await accountRepository.saveAll([fromAccount, toAccount]);
return { success: true, newBalance: fromAccount.balance };
}
The try/catch in the first version is a confession: "I don't actually know if this will work." The second version knows.
Runtime Validation: Types Are Not Enough
This is the piece people skip, and it's the one that bites them. Your type system is a compile-time fiction. It tells you nothing about the trash arriving at runtime.
// Types do not stop this
const userInput: string = JSON.parse(request.body).email; // could be literally anything
const email: EmailAddress = userInput; // TypeScript shrugs and says fine
People confuse compile time and runtime constantly. You can type whatever you want. It will not stop bad data from walking through the front door. You need a runtime validation step that complements the types.
function validateEmailAddress(input: unknown): EmailAddress {
if (typeof input !== 'string') {
throw new ValidationError('Email must be a string');
}
if (!input.includes('@') || !input.includes('.')) {
throw new ValidationError('Email must contain @ and . characters');
}
if (input.length > 254) {
throw new ValidationError('Email too long');
}
return input as EmailAddress;
}
// Now you can trust it
const email = validateEmailAddress(userInput);
When to Skip Validation
There's exactly one time to skip it: an internal function operating on data that was already validated at the system boundary, where raw speed matters. These functions receive normalized, predictable, consistent input, so they can't fail. They've already been through the clean room.
// Internal: runs only on pre-validated data
function calculateVectorLength(x: number, y: number, z: number): number {
return Math.sqrt(x * x + y * y + z * z);
}
// Public boundary: validation lives here
function calculateDistance(point1: Point3D, point2: Point3D): number {
validatePoint3D(point1);
validatePoint3D(point2);
// The internal call doesn't re-validate. It doesn't need to.
return calculateVectorLength(
point2.x - point1.x,
point2.y - point1.y,
point2.z - point1.z
);
}
Why This Pays Off
This reads as more verbose than "move fast and break things," and it is, up front. But the cost curve runs the other way over time:
- Debugging gets trivial. Failures happen loudly at the validation boundary, not three layers deep where the data's already corrupted.
- Code review gets easier. The four blocks make intent obvious at a glance.
- Testing gets systematic. You can verify each block on its own.
- Maintenance gets predictable. Every function has the same shape, so a new reader knows where to look.
Add it up and you spend less time fixing bugs and more time building, because bugs get genuinely rare when you flat-out refuse to attempt operations that might fail.
Ancient Rules, Current Problems
None of this is about being academic. The real challenges in programming, managing complexity, guaranteeing correctness, and communicating intent, are logic problems, and people have been chewing on logic for millennia. We didn't invent these problems. We just rediscovered them in TypeScript.
When you name things what they are, keep values from being two opposite things at once, refuse to let ambiguity sit in some undecided middle state, write logic that reads like its own proof, and decline to try operations that will fail, you're not cosplaying as a Greek philosopher. You're applying the oldest, most tested rules of clear thinking to the very modern job of making a machine do something useful.
Frameworks come and go. Logic is eternal.
Stay in the Loop!
Be the first to know - subscribe today
Member discussion