DEV Community

Cover image for Typescript advanced bits: function overloading, never and unknown types
Ivan Kolotilov
Ivan Kolotilov

Posted on

Typescript advanced bits: function overloading, never and unknown types

Typescript is a powerful tool that helps build reliable products, develop codebases that scale, and improve the developer experience. While it's quite popular nowadays, some aspects are sometimes overlooked.

As a massive fan of Typescript, I want to draw your attention to some of them and talk a bit about how they work, what could be the potential use cases and what benefits they bring. In this article, we'll be looking at function overloading as well as unknown & never types.

Function overloading

Sometimes you may find yourself in a situation where you want a function that needs to work with more than one set of parameters and produce results of different types. Usually, to achieve this, we can use optional parameters and unions. But in some cases, this does not let us describe how parameters and return types are associated concisely enough. So we lose on readability and quality of hints from our IDE. This is where function overloading can help us.

Function overloading in Typescript is a fantastic feature that lets you define multiple function signatures one after another, describing what the function will return depending on what parameters are passed.

To do this, we write two kinds of signatures - overloading signatures and implementation signatures. Overloading signatures does not have an implementation (function body) and just defines the typings. It's important to remember that the implementation signature should be compatible with all the overloading signatures.

Let's look at an example from an imaginary car-renting app. We need a universal function for getting a car booking price. It accepts some information about the car and returns a single price or an array of prices, depending on parameters. Also, if a callback is passed, then it will be called with the price, and in this case, the function will return. Depending on what parameters are passed, it calls two different functions to retrieve the price from the backend (that part is simplified for explanatory purposes). This is what it may look like:

const getCarPricesByCarParams = (
    carType: string,
    city: string,
    distance?: number)
: number[] => {
  return [500, 1000, 1500]
}

const getPriceByCarId = (carId: string):number => {
  return 1000
}

function getBookingPrice(carId: string): number
function getBookingPrice(
    carId: string,
    callback: (price: number) => void)
: void
function getBookingPrice(
    carType: string,
    city: string,
    distance: number)
: number[]
function getBookingPrice(
    carIdOrType: string,
    callbackOrCity?: ((price: number) => void) | string,
    distance?: number)
: number | void | number[] {
  if (typeof callbackOrCity === 'string') {
    // we're returning a number[] type
    return getCarPricesByCarParams(
  carIdOrType,
  callbackOrCity,
  distance)
}
  if (typeof callbackOrCity === 'function') {
    const price = getPriceByCarId(carIdOrType)
    callbackOrCity(1)
    // we're not returning anything here
    // (so function has void return type)
    return
  }
  // we're returning a number type
  return 1000
}
Enter fullscreen mode Exit fullscreen mode

This approach is helpful when the function is complex and returns multiple types. Function overloading enables us to establish strict relationships between parameter types and returned types. If your function gets this complex, a good way to deal with it might be as well splitting it into multiple functions.

Another alternative to function overloading is using Union types in parameters. It's a good idea to experiment with both approaches to identify what feels right for your specific case.

Unknown type

Apart from well-known and straightforward primitive types like string, number, and boolean, the typescript type system has some types that could confuse new developers. Those are never and unknown. Let's look at those types, what they mean and how they can help us.

Unknown type represents a set of all possible values of every possible type. If we declare a variable of this type, any value can be assigned to it. According to set theory that Typescripts heavily relies on in its type system, unknown represents a top type (another top type is any). It means it can describe any possible value.

A good way to think about unknown is that it's a more typesafe version of any. The difference with any is that if we type something as any Typescript permits us to do anything with it. For example, access any properties as if they exist. If you declare a variable of unknown type, Typescript won't let you use it (i.e. access nested properties, call methods or perform operations) unless you narrow it down to a more specific type. And this is much more typesafe.

For this, you can use type guards with typeof, instanceof or other type narrowing techniques or assertions. Using generally makes your code less typesafe, so other type guarding techniques are preferable in most cases.

let someValue: unknown

// TS will complain about the following:

someValue.doSomething();   // error
someValue.someOtherValue   // error
someValue();               // error

// But if we narrow the type down, 
// TS will understand what it is and let us use it.

if (typeOf someValue === 'string') {
  console.log(someValue.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

A good way to significantly improve the reliability of your app is via improving type-safety by moving away from using any to unknown. One relevant example could be when you type your backend responses and when stringifying JSON to using unknown combined with some sort of runtime type checking. It can be done either by using built-in functionality like type guards or using an external library like io-ts, zod or yup.

Never type

never is a primitive type in Typescript representing a bottom type. It is used to describe a type that should never occur. This could be a return type of function that throws an error unconditionally or a state that you want to avoid.

One particularly helpful use case is using it as a way to ensure that all the variations of a particular data structure are handled.

For example, imagine we are building a car renting app, and we have a function that should handle all car types, returning a description for each of them. So we can add a simple shouldNotBeReached helper using never type, and rest assured that once the new car type is added to the codebase, the Typescript will let us know that it needs to be handled.

const shouldNotBeReached = (value: never): never { 
  throw new Error('This should not be reached')
}

type CarType = 'Hedgeback' | 'Sports'

const getCarTypeDescription = (carType: CarType) => {
  switch (carType) {
    case 'Hedgeback': 
       return 'So basically it is a two or four-door car with a tailgate that flips upwards'
    case 'Sports': 
      return 'Well, it is a car designed for getting somewhere really fast and look cool in the process'
   default: 
      return shouldNotBeReached(carType) 
  }
}
Enter fullscreen mode Exit fullscreen mode

Later on, we may want to add some other kind of cars to our app, let's say an SUV. It's easy to forget to update all the functions dealing with this type. To avoid this, we'll add a simple function to the default case of the switch statement. It will make sure Typescript reminds us to handle that new car type.

Conclusion

In this article, we explored three advanced Typescript features along with some use cases. Function overloading can be helpful when working with functions that can accept different sets of parameters and return different types depending on that. Using unknown and never types helps us write more robust and typesafe code and avoid bugs.

Top comments (0)