DEV Community

Cover image for TypeScript Return Types: Separating Fact from Fiction
Pedro Figueiredo
Pedro Figueiredo

Posted on

TypeScript Return Types: Separating Fact from Fiction

TypeScript Return Types

This subject has been trending a lot on Twitter lately and there have been a lot of discussions, videos and different views around that topic.

It's a topic that sparks different perspectives based on one's background and approach to coding. Some may prefer to focus on the practical aspect, while others might lean more toward the theoretical side.

Let's just say that in the end, there are no rights or wrongs and it's not something you or I should be spending much time thinking about.

TypeScript Inference

...type inference is used to provide type information when there is no explicit type annotation - TS docs

TypeScript being smart and being able to infer a function's return type without any hints, really is the only reason why this discussion even exists.

This doesn't mean that we should solely rely on TypeScript's type inference, as there are situations where explicitly defining the types is certainly the best approach.

TypeScript Functions

Another reason for this discussion stems from the fact that many developers from various backgrounds, not just frontend and TypeScript, have a habit of defining contracts or interfaces first when coding.

However, the current frontend scene is quite different. It focuses on functional programming with a touch of composability, leading to a lot of small and well-defined functions that can be easily combined to achieve more complex tasks.

Infer by default

If you are building a TS based product, chances are that you have a lot of small and simple functions that are self explanatory like:

  • getName
  • getAge
  • getUser
  • canRemoveItem
  • onModalOpen
  • ...

By looking at the names of those functions, you can easily deduce their return type. If the return type is not what you expect, it's likely not the type that needs to be changed, but rather the function name itself.

This means that by default, a simple and well defined function shouldn't need explicit return types, it should be implicit by its semantics, or in other words, inferred.

Advantages of inferring

Relying on inferred return types not only simplifies the process, but it also brings several key benefits, including:

  1. Development Speed
    Avoiding the need to define return types for small functions and reducing the need to revisit them as code grows leads to faster software development with equal quality.

  2. Code Maintainability
    Defining return types add extra code that must be maintained, potentially leading to adjustments for small functions with well-defined boundaries, resulting in unnecessary complexity.

  3. Source of Truth
    When you rely on the inferred return type, the source of truth will always lay on your code's implementation, whatever you return, will always stick as the return type.

The same, however, isn't true if you implicitly define it, as you can "easily" override the correct return type by mistake:

type Animal = {
    name: string,
    age: number,
}

function getAnimal(): Animal{
    const dog = {
        name: "Joey",
        age: 7,

// TS accepts this property, but Animal doesn't have it ❌
        breed: "Boxer"
    }

    return dog;
}
Enter fullscreen mode Exit fullscreen mode

When to use Return Types

If by default we should allow inference to do the dirty work, then, in which situations should we be explicitly defining a function's return types?

There are actually some situations where it's useful to define it and more than not, it is related to the function's own complexity.

1. Narrowing return types

type SuccessAction<T> = {
    type: "SUCCESS_FETCH";
    payload: T;
};

function successAction<T>(payload: T): SuccessAction<T> {
    return {
        type: "SUCCESS_FETCH",
        payload,
    };
}
Enter fullscreen mode Exit fullscreen mode

One common scenario, is when you define "action creator" functions that return a store's action (Redux or not).

There are actually 2 reasons to define the return type here:

  1. If we don't define it, the type property will be string instead of the literal "SUCCESS_FETCH".
  2. It makes way more sense to see SuccessAction as the return type, instead of just a "random" object.

2. Multiple code branches

It's very common for functions to get big and complex and with a lot of code branches that may lead to different return types.

If that's the case, you will NOT want to see a bunch of unions of different return types on your screen.

type Action = "CLOSE_TODO" | "CREATE_TODO";

// This leads to a pretty loose union of return types ❌
function handleTodo(action: Action) {
    switch (action) {
        case "CLOSE_TODO":
            return {
                status: "closed" as const,
                closedTimestamp: Date.now()
            }
        case "CREATE_TODO":
            return {
                status: "new" as const,
                createTimeStamp: Date.now()
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

Looking at the sample of code above, it's easy to understand that the more code branches, the more possibilities there are to return the wrong thing, even more if the return type changes depending on some condition (like this case).

// with defined return type ✅
type TodoSate = {
    status: "closed",
    closedTimestamp: number,
} | {
    status: "new",
    createTimeStamp: number,
};

type Action = "CLOSE_TODO" | "CREATE_TODO";

function handleTodo(action: Action): TodoSate  {
    switch (action) {
        case "CLOSE_TODO":
            return {
                status: "closed" as const,
                closedTimestamp: Date.now()
            }
        case "CREATE_TODO":
            return {
                status: "new" as const,
                createTimeStamp: Date.now()
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

Defining the return type in these sorts of scenarios, can have major advantages such as:

1- Not allowing impossible states, like having the status open and the closedTimestamp defined.
2- Not allowing typos in any of the returned objects.
3- It's much more scalable as the functions grow in size/complexity.

3. Library's code

For library's code, however, the story is much different when it comes to return types. The main rule here is, for every function that is being publicly exposed, you should define the return type and the reasons are pretty simple:

1- Consumers should be able to import and utilize the return type of a function in order to effectively reuse and compose the library's functionalities.
2- Having a well-defined return type "name" saves developers time and eliminates the need to constantly refer to type definition files.

Conclusion

When using TypeScript for development, it's best to rely on inferred return types and only define them in specific cases. This approach leads to faster development and reduces the risk of errors, improving both speed and reliability of your code.


Make sure to follow me on twitter if you want to read about TypeScript best practices or just web development in general!

Top comments (1)

Collapse
 
ryanzayne profile image
Ryan Zayne

Simply wonderful! Thanks for helping me make an overly informed decision 🥂