TypeScript Types: Why Do I Have to Spell It Out Twice?
If you’ve been using TypeScript for a while, you’re probably familiar with its powerful type-checking features that help catch errors during development. Types, interfaces, and enums are crucial to shaping the structure of your code and ensuring it’s robust. However, you may have stumbled upon a little inconsistency when dealing with class vs. function implementations that can make you question, “Why doesn’t TypeScript infer my types automatically here?”
Let me explain.
The Setup: Types in TypeScript
Let’s consider a basic setup for a ticketing system.
enum TicketStatus {
OPEN = "OPEN",
IN_PROGRESS = "IN_PROGRESS",
CLOSED = "CLOSED",
}
interface CreateTicketRequest {
title: string;
description: number;
}
interface CreateTicketResponse {
id: string;
title: string;
description: string;
status: TicketStatus;
createdAt: Date;
updatedAt: Date;
}
interface DatabaseService {
createTicket(data: CreateTicketRequest): Promise<CreateTicketResponse>;
}
We’ve defined an interface for the ticket creation request (CreateTicketRequest
) and response (CreateTicketResponse
), along with the DatabaseService
interface that declares the createTicket
method.
The Class Implementation: Explicit Typing
Let’s implement this service using a class.
class MongoDatabase implements DatabaseService {
async createTicket(data: CreateTicketRequest): Promise<CreateTicketResponse> {
// Implementation here
}
}
In the class-based implementation, we explicitly state the type of data
as CreateTicketRequest
and the return type as Promise<CreateTicketResponse>
. This is because TypeScript requires us to honor the contract defined by the DatabaseService
interface.
The Function Implementation: Implicit Typing
Now, let’s look at the same service, implemented as a function.
function MongoDatabase(): DatabaseService {
return {
async createTicket(data) {
// Implementation here
}
}
}
Surprisingly, in the function-based implementation, we don’t need to declare the type of data
explicitly. TypeScript automatically infers it from the DatabaseService
interface.
Since you’ve explicitly stated that the function returns a DatabaseService
, TypeScript knows that the createTicket
method should match the interface's signature. Therefore, it automatically infers the parameter and return types without you needing to specify them explicitly.
“So, why does this work?”
The reasons for this difference
Historical and Design Choices
- Classes in TypeScript were designed to be more explicit, following patterns from languages like Java or C#.
- Functions and object literals were designed to be more flexible and leverage TypeScript’s powerful type inference capabilities.
Type Checking Approach
- For classes, TypeScript checks if the class correctly implements the interface by comparing the explicitly declared methods.
- For functions returning an interface type, TypeScript uses structural typing to check if the returned object matches the interface structure.
Contextual Typing
- In the function version, the return type provides context for TypeScript to infer the method signatures.
- Classes don’t have this surrounding context, so explicit annotations are required.
Flexibility vs Explicitness
- The function approach offers more flexibility and can be less verbose.
- The class approach is more explicit, which can benefit readability and catching errors early in larger codebases.
Conclusion
Both approaches have their merits, and the choice between them often comes down to coding style preferences and specific project requirements. The function approach can lead to more concise code, while the class approach provides more explicit type-checking at the cost of verbosity.