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

Handling void by nullish coalescing operator #40613

Closed
michaldudak opened this issue Sep 17, 2020 · 9 comments
Closed

Handling void by nullish coalescing operator #40613

michaldudak opened this issue Sep 17, 2020 · 9 comments

Comments

@michaldudak
Copy link

TypeScript Version: 4.0.2

Search Terms: nullish coalescing, void

Code

type PossiblyVoid = () => void | boolean;
let empty : PossiblyVoid = () => { }
let coalesced : boolean = empty() ?? true;

Expected behavior:
Should not throw a compilation error

Actual behavior:
Nullish-coalescing operator does not treat void variables as undefined, even though in JS a function that does not return anything is the same as the one returning undefined.

I am not entirely sure this is a bug, but intuitively it feels like is should behave in a different way. If not, could you please shed some light on the reasons behind it?

Playground Link:
https://www.typescriptlang.org/play?ts=4.0.2#code/C4TwDgpgBACg9gZwQSwEYBsQDU7ICZQC8UAFAJREB8UAbrgQD5SpxzoQCGAdgNwBQ7YFAgBbMKCgAuWIhQZs9IqQqFqAbygBfARCEBjOB3YI9EAtJZtOXJaPEhyUAPxOowAE4BXCPyA

@j-oliveras
Copy link
Contributor

This is working as intended (see #26234). Imagine:

ffunction func(): any {
    const d = Math.random();
    if (d < 0.25)
        return 1;
    if (d < 0.5)
        return "-";
    if (d < 0.75)
        return { a: 1};
    return 0;
}

type PossiblyVoid = () => void | boolean;
let empty : PossiblyVoid = func;
let coalesced : boolean = empty() ?? true;

Playground.

The declared func is assignable to PossiblyVoid type but the real value of coalesced will be 1 | "-" | { a: 1} | 0 instead of a boolean.

@MartinJohns
Copy link
Contributor

Expanding on what @j-oliveras said:

A function returning void doesn't mean it returns nothing, but it returns something that should not be used for anything.

If you intend to have a "nothing" value you should use undefined.

@michaldudak
Copy link
Author

michaldudak commented Sep 18, 2020

All right, I get it now.

The problem I have with () => undefined is that a function must have an explicit return statement, so a function like function fn() : undefned {} does not compile (even though JS adds an automatic return undefined to it.

Playground

@michaldudak
Copy link
Author

Actually, I found an issue that describes this: #36288

@barryam3
Copy link

A function returning void doesn't mean it returns nothing, but it returns something that should not be used for anything.

In that case, how come the ternary and or operators work?

function func(): any {
    const d = Math.random();
    if (d < 0.25)
        return 1;
    if (d < 0.5)
        return "-";
    if (d < 0.75)
        return { a: 1};
    return 0;
}

type PossiblyVoid = () => void | boolean;
let empty : PossiblyVoid = func;
const emptyReturn = empty();
let nullishCoalesced : boolean = emptyReturn ?? true;  // error
let orCoalesced : boolean = emptyReturn || true;  // ok
let ternaryCoalesced: boolean = emptyReturn ? emptyReturn : true;  // ok

Playground

@jdforsythe
Copy link

jdforsythe commented Sep 24, 2021

@RyanCavanaugh Can this be re-opened for discussion? The behavior of the nullisch coalesce vs if for type narrowing with void is not consistent.

Expanding on what @j-oliveras said:

A function returning void doesn't mean it returns nothing, but it returns something that should not be used for anything.

If you intend to have a "nothing" value you should use undefined.

Actually writing a void-returning function that has any value other than undefined is tricky.

function testOne(): void {
  return 1; // type 'number' is not assignable to type 'void'
}

function testTwo(): void {
  console.log('void');
} // returns 'undefined'

function testThree(): void {
  return undefined;
} // returns 'undefined'

function testFour(): void {
  return;
} // returns 'undefined'

function testFive(): boolean | void {
  return;
} // returns 'undefined'

function testSix(): boolean | void {
  return true;
} // returns '<boolean> true'

function testSeven(): boolean | void {
  return 1; // type '1' is not assignable to type 'boolean | void'
}

The only way I can figure out to make it happen is:

function test(cb: () => any): void {
  return cb();
}

function callback(): boolean {
  return true;
}

test(callback);

This just kind of disables the type check, and since we disallow any, it can't actually happen for us. The example works with any but not with unknown or never.

Barring the example with any, is there any time where void is actually some value other than undefined?

--

I found this issue while searching information about void and the nullish coalesce. We use an explicit | void for certain functions to force developers to handle the undefined scenario. For instance, with a database SELECT for a single record, there could be no result. So here's a simplified example:

// db library function
export const selectOne = <T>(query: { query: string, replacements: number[] }): Promise<T | void> => {
  const res = await dbPool.query(query, replacements);

  const rows = res.rows;

  if (!rows.length) {
    return undefined;
  }

  if (rows.length > 1) {
    throw new Error('Multiple rows returned');
  }

  return rows[0];
};

// service function
export const getRowByPrimaryKey = (id: number): Promise<SomeRecord | void> => {
  const db = getDb();

  const query = getQuery(id);

  return db.selectOne<SomeRecord>(query.query, query.replacements);
};

Consumer example 1:

// the `| void` forces the developer to think about what to do if nothing is returned
// and indicates that the function purposely returns undefined in some scenarios
const record: SomeRecord | void = await getRowByPrimaryKey(1);

if (!record) {
  console.log('No record returned');

  return;
}

// this works because after the if, TypeScript narrows `record` to the `SomeRecord` type, dropping the `void`
const { prop } = record;

Consumer example 2:

const record: SomeRecord | void = await getRowByPrimaryKey(1);

// this does not work - 'Property 'prop' does not exist on type 'void | SomeRecord | { prop: number }'
const { prop } = record ?? { prop: 1 };

// you can force it by explicitly typing
const { prop } = <SomeRecord> record ?? { prop: 1 };

So oddly the if will narrow the type, which seems to say that in that case TypeScript treats void as falsy and not as just a value that shouldn't be used. So the behavior of type narrowing between the if and nullish coalesce is not consistent.

Of course using | undefined does not work the same way as | void and basically has zero effect on anything, since any value can be undefined. So this is the only way we've found to explicitly type that a function can purposely return undefined and that the developer needs to explicitly handle that scenario.

@MartinJohns
Copy link
Contributor

MartinJohns commented Sep 24, 2021

@jdforsythe

The only way I can figure out to make it happen is: [...] This just kind of disables the type check, and since we disallow any, it can't actually happen for us. The example works with any but not with unknown or never.

The example works with void:

function test(cb: () => void): void { return cb(); }
function callback(): boolean { return true; }

// Return type is void, actually returned value is true.
test(callback);

We use an explicit | void for certain functions to force developers to handle the undefined scenario.

Frankly, then you're abusing void. If you want to force the developers to handle the undefined scenario, then use undefined. void does not mean undefined. In a way void is the same as any, at least when it comes to possible values to deal with.

@jdforsythe
Copy link

@MartinJohns

Indeed, we are abusing void, however using type undefined does not provide any benefit.

function isPositive(input: number): true | undefined {
  if (input < 1) {
    return undefined;
  }

  return true;
}

// val is type `true`
const val = isPositive(-1);

Adding the | undefined doesn't change anything about the types. There's no warning that val might be undefined, and in essence any type T is T | undefined

const bool: true = undefined;

There's some inconsistency in the behavior for void. It seems TypeScript only has a problem testing for truthiness if the type is simply void but not if it's unioned with another type. In fact, it can be completely wrong.

const maybeVoid = (): void | number => Math.random() < 0.5 ? alwaysVoid(callback) : 0;

const alwaysVoid = (cb: () => void): void => cb();
const callback = (): boolean => true;

function try1() {
  const rec = alwaysVoid(callback);

  if (!rec) { // An expression of type 'void' cannot be tested for truthiness
    return;
  }
}

function try2() {
  const rec: number | void = maybeVoid();

  if (!rec) {
    console.log('number | void, but actually can only be number here', rec);

    return;
  }

  console.log('number, but actually can only be void here', rec);
}

In try2 TypeScript has no problem checking truthiness of !rec even though it could be void, and in this case gets it exactly backwards. Inside the if block, it types rec as number | void even though it could only be a number (0) there, and after the if it types rec as number even though it can only be void (true).

@MartinJohns
Copy link
Contributor

Adding the | undefined doesn't change anything about the types. There's no warning that val might be undefined, and in essence any type T is T | undefined

It makes a difference if you enable strictNullChecks, a feature existing since TypeScript 2.0 (released in 2016).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants