Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#AvoidTheVoid Allow users to avoid the void type. #42709

Open
4 of 5 tasks
brainkim opened this issue Feb 9, 2021 · 8 comments
Open
4 of 5 tasks

#AvoidTheVoid Allow users to avoid the void type. #42709

brainkim opened this issue Feb 9, 2021 · 8 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@brainkim
Copy link

brainkim commented Feb 9, 2021

Suggestion

Allow developers to avoid the void type.

AVOIDTHEVOID

I’d like to propose a couple changes to TypeScript to make void an optional type, and infer undefined in more places where void is currently used. I’ve argued in multiple places that TypeScript’s void type is confusing and detrimental to development, and I was asked by a maintainer to create the following document to outline the problems with void and propose potential solutions.

The Problem

First, what is void? According to the TypeScript handbook:

void is a little like the opposite of any: the absence of having any type at all. You may commonly see this as the return type of functions that do not return a value.

In practice, the void type is more or less a black hole kind of type. Nothing is assignable to void except itself, undefined and any, and void is not assignable to anything except itself, unknown and any. Additionally, typical type narrowings like typeof x === "string" do not narrow void, and void infects type unions like void | string by making the resulting type behave like void.

The handbook hints at the original use-case for void, which is that it was used to model functions which don’t return, i.e. functions without a return statement. Although the runtime behavior of these functions is to return undefined, maintainers argued for modeling these functions as returning void instead at the type level because implicit return values are seldom used, and if they are used this usually indicates some kind of programmer error. The reasoning was that if you actually wanted a function to have a return type of undefined, you could do so by explicitly returning undefined from the function.

This section of the TypeScript handbook is a good start, but it doesn’t fully address the intricacies of the void type. While void behaves mostly like a special type alias for undefined when used in typical assignments, it behaves more like unknown when used as the return type of a function type (() => void).

 // these functions should return implicitly or will error
function fn1(): void {}
const fn2 = (): void => {};

// only undefined or void can be assigned to void variables
const v: void = undefined;

// void in function types behaves like unknown insofar as it allows the assigned function to return anything
const callback: () => void = () => 1337;

Again, this makes sense according to the original motivations for void: if you’re marking a callback’s return type as void, then you don’t really care what the callback returns, because you’ve essentially promised that you’ll never use it. It’s important to note that void came very early in TypeScript’s development, and neither the unknown type nor even strict null checks existed at its point of creation.

Unfortunately, this dual nature of void as being either undefined or unknown based on its position has an undesirable consequence, which is that while undefined is always assignable to void, void is not assignable to undefined. Personally, I noticed this issue with increasing frequency because of my work with async and generator functions, to the point where I felt that the void type was getting in my way. These function syntaxes also did not exist when the void type was created, and their very existence violates the key assumption which motivated the creation of the void type in the first place, that we don’t or shouldn’t use the return values of functions with implicit returns.

This assumption collapses when we consider async and generator functions because we more or less must use the return values of async and generator functions even if they return implicitly. Many async functions have implicit returns, where the function body is used to await other promises, and the vast majority of generator functions do not contain return statements at all. Despite this, it would be a basic programmer error not to use the returned promises or generator objects in some way, so much so that there are even lint rules like no-floating-promise to prevent you from ignoring the return values of these types of functions.

Because we must use the return values of async and generator functions, and because they are inferred to return compositions of void like Promise<void> or Generator<any, void>, void types inevitably leak into assignments. I have frequently been frustrated by attempts to assign Promise<void> to Promise<undefined>, or Generator<any, void> to Generator<any, undefined>; invariably, the error pyramids which TypeScript emits all end with void not being assignable to undefined.

class Example {
  _initPromise: Promise<undefined>;

  async initialize() {
    // some async setup code
    // no explicit return
  }

  constructor() {
    // A compiler error because initialize is inferred to return Promise<void>.
    this._initPromise = this.initialize();
  }
}

function *integers() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

// A compiler error because value is inferred as number | void.
const value: number | undefined = integers().next().value;

The TypeScript lib definitions also leak void into assignments, with Promise.resolve() called with no arguments being inferred as Promise<void>, and the promise constructor explicitly requiring undefined to be passed to the resolve() function if the promise’s type parameter is undefined and not void.

All these details make trying to avoid void painful, almost impossible. We could explicitly return undefined from all non-returning functions and call Promise.resolve(undefined) to create a non-void Promise, but this violates one of the main goals of TypeScript, which is to produce idiomatic JavaScript. Moreover, it is not a workable solution for library authors who wish to avoid the void type, insofar as it would require libraries to impose the same requirements of explicit usages of undefined on their users.

Here’s the thing. If we were to do a survey of all the places where the void type is currently inferred, I would estimate that in >95% of these positions, the actual runtime value is always undefined. However, because we can’t eliminate the possibility that void is being used like unknown, we’re stuck leaving the voids in place. In essence, TypeScript is not providing the best possible inference for code wherever void is inferred. It’s almost as if TypeScript is forcing a nominal type alias for undefined on developers, while most TypeScript typing is structural.

Beyond producing unnecessary semantic errors, the void type is very confusing for JavaScript developers. Not many developers distinguish the return type of function declarations from the return type of function types as two separate type positions, and void is (probably?) the only type whose behavior differs based on its usage in these two places. Furthermore, we see evidence of this confusion whenever people create unions with void like Promise<void> | void or void | undefined. These union types are mostly useless, and hint that developers are just trying to get errors to go away without understanding the vagueries of void.

The Solution

To allow the void type to be optional, I propose we make the following changes to TypeScript.

Allow functions which have a return type of undefined or unknown to return implicitly.

// The following function definitions should not cause compiler errors
function foo(): undefined {
}

function bar(): unknown {
}

async function fooAsync(): Promise<undefined> {
}

function *barIterator(): Generator<any, unknown> {
}

Currently, attempting to type the return types of functions with implicit returns as undefined or unknown will cause the error A function whose declared type is neither 'void' nor 'any' must return a value. The any type is unacceptable because it makes functions with implicit returns type-unsafe, so we’re left with void. By allowing undefined or unknown returning functions to return implicitly, we can avoid void when typing implicitly returning functions.

This behavior should also extend to async and generator functions.

Related proposal #36288

Infer functions with implicit returns and no return type as returning undefined.

There are many situations where we would rather not have void inferred. For instance, when using anonymous callbacks with promises, we can unwittingly transform Promise<T> into Promise<T | void> rather than Promise<T | undefined>.

declare let p: Promise<number>;
// inferred as Promise<number | void> while Promise<number | undefined> would be SO MUCH NICER here.
const q = p.catch((err) => console.log(err));

I propose that all functions with implicit returns should be inferred as returning undefined when no return type is provided.

This behavior should also extend to async and generator functions.

Related proposal: #36239

Infer Promise.resolve() as returning Promise<undefined> and remove any other pressing usages of void in lib.d.ts.

I’m not sure if there are other problematic instances of void in TypeScript’s lib typings, but it would be nice if the idiomatic Promise.resolve() could just be inferred as Promise<undefined>. We could also change callback types to use unknown instead of void but that change feels much less necessary to avoid void.

Allow trailing parameters which extend undefined to be optional.

One problem with the semantic changes above which I haven’t mentioned relates to yet another unusual void behavior. When trailing parameters in function types extend void, they become optional. This kind of makes sense because, for instance, it allows people to omit the argument to the resolve() function of a void promise, or the argument to the return() method of a void returning generator. Because of this, inferring undefined where we used to infer void might inadvertently change the required number of arguments to functions, causing type errors where previously there were none.

Therefore, I think if we were to consider the above proposals, we should also strongly consider changing TypeScript to infer trailing parameters which extend undefined as being optional.

function optionalAdd(a: number, b: number | undefined) {
  return b ? a + b : a;
}

// this should not error
optionalAdd(1);

This might be a more controversial change which bogs down the larger project of avoiding void, but I think we could reasonably implement the earlier proposals without making this change. Furthermore, I also think this change would be beneficial to TypeScript in a similar way to making void optional. Ultimately, I think the larger theme at play is that TypeScript has too many special ways to indicate undefined-ness which don’t affect the runtime and don’t provide any additional type safety.


These changes might seem sweeping, but the inflexible non-assignability of void should make the changes not as hard to upgrade into as they might seem. Moreover, the benefit to the TypeScript ecosystem will be that we can start refactoring void out of our type definitions. By replacing void with undefined and unknown, we can start being more explicit about what exactly our functions return and what we want from our callback types without losing type safety.

Furthermore, I think these changes are in line with TypeScript’s design goals, specifically that TypeScript should “3. Impose no runtime overhead on emitted programs” and “4. Emit clean, idiomatic, recognizable JavaScript code.” By allowing undefined to model implicit returns and optional trailing parameters, we’re merely describing JavaScript as it is, and the use of void to model these values can be surprising.

☝️ Potential Objections

Lots of people will object and say that void is actually a useful type, that the idea of an opposite “dual” of any is interesting or whatever. I don’t care for this kind of astronaut analysis and I don’t think the void type is useful, but any such arguments are immaterial to this proposal. My main problem isn’t that the void type exists, but that it is forced upon me. To be clear, none of my proposals change the behavior of the void type, nor do they seek the removal of the void type from TypeScript. I just want void to stop being inferred or required when I write idiomatic JavaScript. I believe that even if my proposals were implemented, people who actually cared about void could continue to explore the unusual features of this gross type and not know anything had changed.

People may also object that they still want to use the void type, because its inflexibility is useful for typing callbacks, as a guarantee to callback providers that the return value of the callback is never used. I would argue that for the callback case, void is completely superseded by unknown, and that you can use unknown in the place of void when typing first-class functions today without much friction. Furthermore, as the callback provider, if you really wanted to make sure that a callback’s return value is never used, the most idiomatic and foolproof way to do this in JavaScript would be to use the void operator in the runtime (() => void hideMyReturn()). Ironically, TypeScript infers this function as returning undefined and not void.

🔍 Search Terms

void, undefined, implicit return, non-returning function, noImplicitReturns

💣 Relevant Errors

  • TS1345: An expression of type 'void' cannot be tested for truthiness.
  • TS2322: Type 'void' is not assignable to type 'undefined'.
  • TS2355: A function whose declared type is neither 'void' nor 'any' must return a value.
  • TS7030: Not all code paths return a value.

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

🔗 Selected Relevant Issues

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Feb 9, 2021
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Feb 9, 2021

We've spent several dozen person-hours in the past few months beating this particular horse to death without much forward progress, but I remain optimistic that we'll eventually make some progress on it.

@DanielRosenwasser
Copy link
Member

This does sound like it's in the same spirit of #41016.

Maybe we should type catch clauses as void. 😄

@MattiasMartens
Copy link

Coming in with the most embarrassing counterpoint ever: I like 'void'. I like typing it, that is, I enjoy the experience clacking V-O-I-D on my keyboard.

I do not like clacking U-N-K-N-O-W-N on my keyboard. Not knowing things makes me feel like I don't understand my own code, which makes me feel like a bad programmer. Clacking A-N-Y on my keyboard makes me feel like my keyboard should be taken away and given to a responsible adult.

More seriously, I do feel your pain with regard to how void is conceptualized at the moment. The way I see it, the problem that hurts everyone, regardless of their preferred style, is that void has a split meaning: it can mean "this came from a function with an indeterminate return type and therefore cannot be correctly dereferenced" or it can mean "this is undefined, but it came from a context where undefined was not declared explicitly". In many simple cases, these are the same, but as TypeScript's engine has grown more and more sophisticated (and hence is being applied to increasingly complex builds), we see that they sharply diverge.

Initially, it may have seemed like the difference would be obvious from context:

// This has type () => void
const x = function() {

}

// This has type 2 | undefined
const y = function() {
  if (1 + 1 === 2) {
    return 2
  }
}

It must have been judged as a reasonable inference that functions that look like y() are functions whose output matters to a caller, whereas functions that look like x() are doing functions, and referring to their return value is evidence of a programmer mistake.

But there are simple cases where this assumption already leads the type checker astray:

const composeOnCondition = <A, B>(cond: () => boolean, fn1: () => A, fn2: () => B) => cond() ? fn1() : fn2()

const z = composeOnCondition(() => 1 + 1 === 2, x, y)

Here the type of z is 2 | void | undefined, which is unhelpful. ("This variable might be known at compile time... or it might not?") It appears that the output of fn1 is, in the end, important to the programmer's intent, but the type-checker can't account for this retroactively.

I'm reviewing your suggestions to see if I actually disagree with any of them. I think the answer is: I don't. I'm surprised by that given that my feeling about the keyword is much more positive than yours.

Overall, the two definitions of void - one as a synonym for unknown that by convention is associated with function return types, and one as a synonym for undefined that by convention is associated with implicit returns - can't be reconciled over the full range of cases TS is meant to handle. I think everyone is aware of this. I do think that of the two, void as the return type of a non-returning function is by far the more problematic one. I am very much in favour of dropping it. As well, most of the pain points you raised seem to relate to this aspect.

I'll mention a case where I do like void: when a higher-order function takes a function whose return type will not matter to execution:

function compose(fn1: () => void) {

}

// I like that this works
compose(() => 2)

However, because this is a case where void is specified explicitly, I don't think your suggestions would affect it. It's also true that unknown would work just as well here, it just doesn't quite feel right.

Also, in the interest of fully airing out my laundry: you mention () => void | Promise<void> as an example of something a programmer might specify when they're just trying to get things to compile, without really caring about the intricacies of the type system. That was not the case for me. My intention was to express something like: "give me a function whose output won't matter - unless it's a Promise, in which case it will be awaited". I don't see a better way to express that intent within TypeScript's paradigm.

The only problem with that at present is that () => void | Promise<void> turns out to be significantly less permissive than you would expect from its plain-language definition. That's the issue I raised in #43921 and which brought me here (thanks to @MartinJohns).

From my playing around, it looks like the situation 'on the ground' is: when void becomes involved in a union type or a generic type, it switches from behaving as an alias for unknown to behaving as an alias for undefined. This is why typeof () => Promise.resolve(2) turns out not to extend () => Promise<void> even though typeof () => 2 does extend () => void.

Needless to say, this arrangement pleases no-one. But if the prickly second definition of void were to be removed - if indeed that is possible at this late date - the issue would disappear.

@jethrolarson
Copy link

JS already has too many "null" concepts and then TypeScript adds more. Especially when void is already a keyword. It's a recipe for confusion. We're stuck with null and undefined in JS but typescript could actually remove (or at least enable devs to avoid) void. never, unknown, and undefined should be enough.

wojpawlik added a commit to telegraf/telegraf that referenced this issue Oct 1, 2022
@brainkim brainkim changed the title #AvoidTheVoid #AvoidTheVoid Allow users to avoid the void type. Oct 12, 2022
@unional
Copy link
Contributor

unional commented Sep 12, 2023

I second what @MattiasMartens said that using void is good.

I want to further suggest that the usage of void should be limited to (encouraged to be limited to) indicating the intent of the author. Specifically,

When declaring a function with explicit return type to enforce the intent.

function foo(): void {}

This avoid the implementation later changed to return something by accident.

As for using unknown in place of void in this case (function foo(): unknown {}),
it is indeed weird (and wrong) because the "return value" is "known" to the author, as the author intents to return "nothing" specifically.

For callbacks, declaring:

const callback: () => void
// or
function fn(handler: (a: A) => void) { ... }

Is really a "suggestion" that "hey, I don't need the return value of the callback/handler. As long as it's a function that takes a parameter a: A I'll be fine".

But when such "suggestion" is being enforced by the compiler, all sort of issues surface.

That's why callback: () => unknown is probably a better signature.

@RyanCavanaugh
Copy link
Member

The reason you want to use void if you intend to not observe a callback's return value is that it can prevent bugs in your code where you e.g. observe the callback value in a truthiness position:

function doSomething(callback: () => void) {
  // Meant to do something else, idk what
  // Errors if using void, otherwise does not
  if (callback()) {

  }
}

@tjjfvi
Copy link
Contributor

tjjfvi commented Sep 13, 2023

It makes sense that that is the case, although I must say that in all my years of TS I've never seen that error:

An expression of type 'void' cannot be tested for truthiness. (1345)

@mkantor
Copy link
Contributor

mkantor commented Mar 9, 2024

In the "infer functions with implicit returns and no return type as returning undefined" section is this example:

declare let p: Promise<number>;
// inferred as Promise<number | void> while Promise<number | undefined> would be SO MUCH NICER here.
const q = p.catch((err) => console.log(err));

However that .catch callback doesn't have an implicit return. It's explicitly returning what console.log returns (which is void). I don't think this part of the proposal would change the type of q (though maybe "remove any other pressing usages of void in lib.d.ts" suggests that console.log should return undefined).

I think you meant this:

declare let p: Promise<number>;
// inferred as Promise<number | void> while Promise<number | undefined> would be SO MUCH NICER here.
const q = p.catch((err) => { console.log(err) });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

8 participants