The Problem with TypeScript Decorators
TypeScript decorators might be making your code harder to maintain. Learn about better alternatives.
Decorators are elegant and powerful, but they can make your code harder to understand, test, and maintain. Here's why, and what to use instead.
Common Anti-Patterns
1. Magic Behavior
// ❌ BAD: Hidden logic in decorators
@Entity()
class User {
@PrimaryKey()
id: string;
@Column()
email: string;
@BeforeInsert()
@BeforeUpdate()
async validate() {
// What else is happening behind the scenes?
// When exactly does this run?
}
}
2. Decorator Soup
// ❌ BAD: Too many decorators
@Controller('users')
@UseGuards(AuthGuard)
@UseInterceptors(LoggingInterceptor)
@UsePipes(ValidationPipe)
class UserController {
@Post()
@UseGuards(RoleGuard)
@UseInterceptors(CacheInterceptor)
@ValidateBody(CreateUserDto)
@ApiOperation({ summary: 'Create user' })
@ApiResponse({ status: 201 })
async createUser(@Body() data: CreateUserDto) {
// The actual logic is buried under decorators
}
}
3. Hidden Dependencies
// ❌ BAD: Implicit dependencies
class OrderService {
@Inject()
private readonly repository: OrderRepository;
@Inject()
private readonly paymentService: PaymentService;
@Transactional()
async createOrder(data: CreateOrderDto) {
// Dependencies and transaction behavior are hidden
}
}
Better Approaches
1. Explicit Configuration
// ✅ BETTER: Clear configuration
interface UserSchema {
id: string;
email: string;
createdAt: Date;
}
const userModel = defineModel<UserSchema>({
name: 'User',
fields: {
id: { type: 'string', primary: true },
email: { type: 'string', unique: true },
createdAt: { type: 'date', default: () => new Date() }
},
validators: {
email: [
validateEmail,
checkUniqueEmail
]
}
});
class UserRepository {
constructor(
private readonly db: Database,
private readonly model: typeof userModel
) {}
async create(data: CreateUserInput): Promise<User> {
await this.model.validate(data);
return this.db.create(this.model.table, data);
}
}
2. Middleware Composition
// ✅ BETTER: Explicit middleware
class UserController {
constructor(
private readonly userService: UserService,
private readonly validator: Validator
) {}
handle = compose(
withAuth,
withValidation(CreateUserSchema),
withLogging('create_user'),
this.createUser.bind(this)
);
private async createUser(
req: AuthenticatedRequest
): Promise<Response> {
const user = await this.userService.createUser({
...req.body,
createdBy: req.user.id
});
return {
status: 201,
data: user
};
}
}
// Middleware composition helper
function compose(...middlewares: Middleware[]): Handler {
return async (req: Request) => {
return middlewares.reduceRight(
(next, middleware) => () => middleware(req, next),
() => Promise.resolve()
)();
};
}
3. Dependency Injection
// ✅ BETTER: Clear dependencies
interface OrderServiceDeps {
repository: OrderRepository;
paymentService: PaymentService;
transactionManager: TransactionManager;
}
class OrderService {
constructor(private readonly deps: OrderServiceDeps) {}
async createOrder(data: CreateOrderDto): Promise<Order> {
return this.deps.transactionManager.run(async () => {
const order = await this.deps.repository.create(data);
await this.deps.paymentService.processPayment(order);
return order;
});
}
}
// Usage
const orderService = new OrderService({
repository: new OrderRepository(db),
paymentService: new PaymentService(paymentGateway),
transactionManager: new TransactionManager(db)
});
Advanced Patterns
1. Method Decorators
// ✅ BETTER: Explicit method enhancement
function withRetry<T>(
options: RetryOptions
): MethodDecorator {
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
let lastError: Error;
for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
lastError = error;
if (!options.shouldRetry(error)) {
throw error;
}
await sleep(options.backoff(attempt));
}
}
throw new RetryError(
`Failed after ${options.maxAttempts} attempts`,
lastError
);
};
return descriptor;
};
}
// Usage
class PaymentService {
@withRetry({
maxAttempts: 3,
shouldRetry: (error) => error instanceof NetworkError,
backoff: (attempt) => Math.pow(2, attempt) * 1000
})
async processPayment(payment: Payment): Promise<void> {
// Core payment logic
}
}
2. Property Validation
// ✅ BETTER: Schema-based validation
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
role: z.enum(['user', 'admin'])
});
type User = z.infer<typeof UserSchema>;
class UserService {
async createUser(input: unknown): Promise<User> {
const validated = UserSchema.parse(input);
// Work with typed and validated data
return this.repository.create(validated);
}
}
3. Aspect-Oriented Programming
// ✅ BETTER: Explicit aspects
interface Aspect {
before?: (context: Context) => Promise<void>;
after?: (context: Context) => Promise<void>;
onError?: (error: Error, context: Context) => Promise<void>;
}
class LoggingAspect implements Aspect {
async before(context: Context): Promise<void> {
console.log(`Starting ${context.method}`, {
args: context.args,
timestamp: new Date()
});
}
async after(context: Context): Promise<void> {
console.log(`Completed ${context.method}`, {
result: context.result,
duration: context.duration
});
}
async onError(error: Error, context: Context): Promise<void> {
console.error(`Error in ${context.method}`, {
error,
args: context.args
});
}
}
// Usage
class PaymentProcessor {
constructor(
private readonly aspects: Aspect[] = [
new LoggingAspect(),
new MetricsAspect(),
new ValidationAspect()
]
) {}
async process(payment: Payment): Promise<void> {
const context = {
method: 'process',
args: [payment],
startTime: Date.now()
};
try {
// Run before aspects
await Promise.all(
this.aspects.map(aspect =>
aspect.before?.(context)
)
);
// Core logic
await this.processPayment(payment);
// Run after aspects
context.duration = Date.now() - context.startTime;
await Promise.all(
this.aspects.map(aspect =>
aspect.after?.(context)
)
);
} catch (error) {
// Run error aspects
await Promise.all(
this.aspects.map(aspect =>
aspect.onError?.(error, context)
)
);
throw error;
}
}
}
Conclusion
Instead of decorators:
- Use explicit configuration
- Compose middleware explicitly
- Make dependencies clear
- Use schema validation
- Consider aspect-oriented patterns
Remember: Code is more maintainable when its behavior is explicit and traceable.