What Is a Contract?
A contract is a set of rules that defines how two parties are expected to interact. It answers three questions:
- what is allowed as input;
- what must be produced as output;
- who is responsible for what.
In real life, this is easy to understand through the example of an employment agreement. An employee agrees to work during the agreed hours, and the employer agrees to pay a salary. If both sides follow the terms, the relationship remains predictable. If not, the contract is broken.
The same idea applies to programming. One module calls another and expects certain behavior. In order for the system to remain reliable, the rules must be clear in advance:
- what data may be passed in;
- what result should be produced;
- what conditions must always remain true.
That is exactly what Design by Contract is about.
Contracts in Programming
In practice, many bugs do not come from complicated logic, but from implicit assumptions. One module assumes it will receive valid data. Another assumes the method will validate everything on its own. As a result, behavior becomes confusing and unpredictable.
Consider this example:
class PaymentProcessingService {
constructor(
private moneyTransferProvider: MoneyTransferProvider,
private commissionRate: number
) {}
// The rules for using this method are not expressed explicitly
processPayment(target: string, amount: number, currency: string): number {
if (!isUUID(target)) {
return 0;
}
if (amount <= 0) {
return 0;
}
const commission = amount * this.commissionRate;
this.moneyTransferProvider.transfer(target, amount, currency);
return commission;
}
}
At first glance, this code looks acceptable, but it has a serious problem: the contract of the method is hidden inside the implementation.
From the method signature alone, it is impossible to understand:
- what kind of
targetis considered valid; - whether any string can be passed as
currency; - what the return value means;
- how to distinguish a successful result from an error.
To use such a method safely, the developer has to read its implementation and guess the rules. That is a bad sign: the contract should be visible in the API, not buried in the method body.
The situation becomes even worse if the system contains several similar services. One may return 0 on failure, another may throw an exception, and a third may skip validation entirely. From the outside, the methods look similar, but their actual behavior is different. This makes the system fragile.
What Design by Contract Offers
Design by Contract addresses this problem by making the rules explicit. A method should not merely perform some action; it should have a clearly defined contract.
A contract usually consists of three parts:
- preconditions — what the caller must guarantee before invoking the method;
- postconditions — what the method must guarantee after execution;
- invariants — properties that must always remain true.
The most well-known implementation of this approach was introduced in the Eiffel programming language. In Eiffel, a contract can be declared directly alongside the method:
class
PAYMENT_PROCESSING_SERVICE
feature
process_payment (target: STRING; amount: REAL_64; currency: STRING): REAL_64
require
target_attached: target /= Void
target_is_uuid: is_uuid (target)
amount_positive: amount > 0.0
currency_attached: currency /= Void
currency_not_empty: not currency.is_empty
currency_supported: is_supported_currency (currency)
local
commission: REAL_64
do
commission := amount * commission_rate
money_transfer_provider.transfer (target, amount, currency)
Result := commission
ensure
calculated_correctly: Result = amount * commission_rate
result_positive: Result > 0.0
end
Even without knowing Eiffel, the idea is clear: before the method runs, the required conditions for the input are listed explicitly; after it runs, the guarantees about the result are also stated explicitly.
This is useful for two reasons.
First, the caller can immediately see how the method is supposed to be used.
Second, the implementation cannot behave arbitrarily: it is obligated to fulfill the promises declared in the contract.
This is especially important in inheritance. If a base class defines a contract, a subclass should not unexpectedly change the rules and require something different from the client.
How to Implement This in TypeScript
TypeScript does not have built-in contract support like Eiffel. We cannot declare invariants and postconditions directly in a method signature in the same way.
But that does not make the idea useless. It simply means the contract has to be expressed differently.
The key principle here is this: the caller is primarily responsible for satisfying the preconditions.
If every method revalidates everything internally, the code quickly turns into a mess of repeated checks. This is often called validation hell: the same validation logic is scattered across multiple layers of the system, and the boundaries of responsibility become unclear.
In TypeScript, the main tools for expressing a contract are:
- parameter types;
- return types;
- domain objects that do not allow invalid states to be created.
The idea is simple: if a method accepts not just string and number, but UuidV4, Money, and Percentage, then part of the contract is already expressed in the signature itself.
Consider an improved version of the example:
class PaymentProcessingService {
constructor(
private readonly moneyTransferProvider: MoneyTransferProvider,
private readonly commissionRate: CommissionRate
) {}
processPayment(target: UuidV4, money: Money): void {
const moneyToTransfer = this.commissionRate.apply(money);
this.moneyTransferProvider.transfer(target, moneyToTransfer);
}
}
Now the method is much clearer.
From the signature alone, we can already see that:
-
targetmust be a validUuidV4; -
moneyis not just a raw number, but an object that already contains a valid amount and a valid currency; - the service does not need to repeatedly validate raw strings and numbers.
In other words, part of the invariants has been moved out of the method body and into the types.
Invariants Inside Domain Types
For example, a valid commission can be represented as a separate type:
class CommissionRate {
private readonly rate: Percentage;
constructor(rate: Percentage) {
this.rate = rate;
}
apply(money: Money): Money {
return this.rate.apply(money);
}
}
Here, CommissionRate does not check whether the percentage falls within the allowed range. That responsibility already belongs to the Percentage type. As a result, the service works only with valid values and does not duplicate validation.
Money can also be modeled as a separate type:
enum Currency {
Euro = 'euro',
USD = 'usd'
}
class Money {
constructor(
private readonly amount: PositiveNumber,
private readonly currency: Currency
) {}
withdraw(money: Money): Money {
if (money.currency !== this.currency) {
throw new InvalidParameterError('Cannot withdraw money of different currencies');
}
return new Money(
this.amount.subtract(money.getAmount()),
this.currency
);
}
getAmount(): PositiveNumber {
return this.amount;
}
getCurrency(): Currency {
return this.currency;
}
}
This kind of type is useful because it keeps the rules for handling money in one place. The system no longer operates on plain numbers. It operates on objects that already define their own constraints and allowed operations.
The same applies to positive numbers:
class PositiveNumber {
private readonly value: number;
constructor(value: number) {
if (value <= 0) {
throw new InvalidParameterError('Value must be positive');
}
if (value > Number.MAX_SAFE_INTEGER) {
throw new InvalidParameterError(
`Value must not exceed ${Number.MAX_SAFE_INTEGER}`
);
}
this.value = value;
}
subtract(other: PositiveNumber): PositiveNumber {
return new PositiveNumber(this.value - other.getValue());
}
getValue(): number {
return this.value;
}
}
Such a class prevents invalid values from being created in the first place. That means any code receiving a PositiveNumber can already rely on the basic validity of the data.
What This Gives Us
This approach makes code:
- easier to understand;
- more predictable;
- less dependent on hidden validation logic;
- more resilient at module boundaries.
Validation is concentrated where values are created, instead of being spread across the entire system. Inside business logic, there is less noise and more clarity.
The Limits of TypeScript
Of course, TypeScript does not give us the same guarantees as Eiffel.
We still cannot:
- fully declare method contracts at the language level;
- strictly enforce postconditions;
- completely protect ourselves from subclasses changing behavior and breaking the base contract.
That is why Design by Contract in TypeScript is not a built-in language mechanism, but rather a design discipline.
Even so, this discipline is still enough to significantly improve code quality. In practice, it helps to:
- make domain types as precise as possible;
- avoid inheritance where composition is sufficient;
- cover important contracts with tests;
- document preconditions and invariants clearly in the API and documentation.
Conclusion
TypeScript cannot implement Design by Contract in its classical form. But it can get close to the same idea through types, value objects, and careful separation of responsibility.
That is the practical value of the approach: not to validate everything everywhere, but to design the system in such a way that as many invalid states as possible become impossible to represent in the first place.
Top comments (0)