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

Intersection with void should resolve to never #55700

Closed
unional opened this issue Sep 10, 2023 · 16 comments
Closed

Intersection with void should resolve to never #55700

unional opened this issue Sep 10, 2023 · 16 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@unional
Copy link
Contributor

unional commented Sep 10, 2023

🔎 Search Terms

intersection, void

🕗 Version & Regression Information

  • This is a crash
  • This changed between versions ______ and _______
  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about void
  • I was unable to test this on prior versions because _______

⏯ Playground Link

https://www.typescriptlang.org/play?#code/C4TwDgpgBAmlC8UDeUCGAuKBnYAnAlgHYDmUAvlAGRQBuA9vgCYBQQA

💻 Code

type Y = { a: string } & void

void is a more restricted type of undefined specifically to describe function that "returns nothing".

In the JavaScript land, it actually returns an implicit undefined.
So for all due and purposes, I believe void should have similar behavior as undefined whenever possible.

For undefined, intersection type resolves to never

type Y = { a: string } & undefined // never

https://www.typescriptlang.org/play?ssl=1&ssc=44&pln=1&pc=1#code/C4TwDgpgBAmlC8UDeUCGAuKBnYAnAlgHYDmUAvlAGRQCuhAJhAGZET1QD0HUhEAbhFwAoIA

🙁 Actual behavior

// Your code here
type Y = { a: string } & void // { a: string } & void

🙂 Expected behavior

// Your code here
type Y = { a: string } & void // never

Additional information about the issue

No response

@MartinJohns
Copy link
Contributor

void is a more restricted type of undefined specifically to describe function that "returns nothing".

This is not really accurate. void means more "absence of any type information". That's also why you can assign functions returning something to functions expecting void as return type.

@unional
Copy link
Contributor Author

unional commented Sep 10, 2023

This is not really accurate. void means more "absence of any type information".
Yes that is true, but:

for the majority of cases, the implicit-return is the typical use case of void:

function foo(): void {
  console.log('foo')
}

Which implicitly returns undefined.

And for

you can assign functions returning something to functions expecting void as return type

at the type level it still means "nothing" (or "absence of any type information"), thus resolving A & void to never is possibly better.

@unional
Copy link
Contributor Author

unional commented Sep 10, 2023

This issue spawn from my work of the Merge type in type-plus. Which tries to do a better job for { ...a, ...b } over A & B because the order matters and improve handling of required and optional properties.

The generalized version of it (which accepts any types, including special types like void and never) should behaves accordingly, and while:

const x = { a: 1 }
const y = undefined

const z = { ...x, ...y }

works, void doesn't. Thus I want to align that behavior.

@jcalz
Copy link
Contributor

jcalz commented Sep 10, 2023

Every time I think about void in TypeScript too much I start feeling this empty despair, like logic has been swallowed up by some great... uh, what's the word? It's like "hole" but that's not it. Gulf? Abyss? Chasm? Ah, never mind. Anyway, void is inconsistent in TypeScript but I don't know that messing with it would be an improvement.

For function return types it's essentially unknown, not undefined, and doing as suggested here would almost certainly break a bunch of existing code. What's the return type of a function which matches both () => void and () => {a: string}? It certainly isn't never. I'd say it's {a: string}, bolstering the unknown interpretation. Indeed I'd rather see a suggestion that void should be given consistent unknown semantics instead of having anything to do with undefined, but that would break a lot of code also.

The current behavior has to be intentional though, so whatever it is, it's not a bug.

@fatcerberus
Copy link

fatcerberus commented Sep 10, 2023

void is essentially a type system fiction - it represents a "value arity" of zero or in other words "there is no value here so don't try to observe it". Of course the runtime reality is that there's always a value there (since the program didn't throw or crash; that would be never), but the value could be anything at all; whatever you observe has to be treated as basically random noise.

@unional
Copy link
Contributor Author

unional commented Sep 11, 2023

For function return types it's essentially unknown

I think that is a bit too pessimistic.
The fact that whether the type system infer the function return type to be void, or user explicitly define the return type to be void, it does carry the meaning that the function does not explicitly return anything.

The function return type is unknown only when the function is completely opaque, or we strictly talk about type-level declaration.

Also, I'm not suggesting we should change the function return type from void to undefined for implicit return (even through I recall there is a flag related to this),

I'm strictly talking about the behavior of intersecting with void, i.e. T & void.

If that is a specific reason for that, yeah that's fine. But I suspect it's something legacy and does not apply today anymore as TypeScript has evolved and the team has better understanding on how it should behave.

@fatcerberus
Copy link

or we strictly talk about type-level declaration.

This is pretty much exactly how the type system operates; it reasons about things based on what’s possible for the types involved and further constraints implied by the specific form of a declaration never come into play.

@unional
Copy link
Contributor Author

unional commented Sep 11, 2023

This is pretty much exactly how the type system operates;

Agree but disagree.
Yes, type system operates at type level.
But we are talking about TypeScript, which describes the behavior of JavaScript.

So while in theory you are correct, at the same time we are not talking about an imagined type system in isolation.
That's why TypeScript need to make choices and occasionally break the soundless of the language because we need to be grounded and align with the JavaScript behavior.

I think a lot of the discussion here is around function signature assignability. i.e.:

const f: () => void = () => 1

Which could be a feature by itself and I agree that changing that would break a lot of code.

But here I'm really try to clarify and describe what void represents and what's it behavior during intersection type.

PS:

TBH, I think that behavior is also legacy and most likely because back then there is no unknown type.

My guess is if the TypeScript team could do this again, such assignment should be prohibited.

@andrewbranch andrewbranch added the Working as Intended The behavior described is the intended behavior; this is not a bug label Sep 11, 2023
@andrewbranch
Copy link
Member

If const f: () => void = () => ({ x: "hello ") is legal, then I think quite clearly void & { x: string } is not never.

@unional
Copy link
Contributor Author

unional commented Sep 12, 2023

I'm not sure I follow the logic. Can you explain?

How does const f: () => void = () => ({ x: "hello ") leads to void & { x: string } is not never?

The former is about assignability on that specific form.

For example, const y: void = { a: 'abc' } is invalid:

https://www.typescriptlang.org/play#code/MYewdgzgLgBAngLhgNxASwCYwLwwN4wCGSA5IQEbAkwC+AsAFBA

the same goes for:

const f: () => void = () => ({ x: "hello " })

let r = f()

r = { x: 'hello' } // invalid as `r: void`

https://www.typescriptlang.org/play?#code/MYewdgzgLgBAZgLhgCgJQwLwD4YDcQCWAJpiutigN4wAeSARABYCmANqyDPTAL6oCwAKCGtmsAE6k4aIUMkYY1OjADkLdiBW9ZgoA

Note that this is also invalid:

// Type '{ x: string; }' is not assignable to type 'void & { x: string; }'.
const f: () => void & { x: string } = () => ({ x: 'abc' })

https://www.typescriptlang.org/play?#code/PTAEBUE8AcFNQOQG9QA8BcoDOAXATgJYB2A5gNygC+CoBWoRA9jqAIZZYElGsBGANvByNQOGPAQA3RgQAmoAGSgUGbPmLkqCAHQBYAFABjRkVygAZpgAUASlABeAHyhpcxcrSZchUlQehbB2crFUwEPkMaShsDIA

Also, what exact does void & { x: string } mean?

For example, boolean & { x: string } boxed the boolean and makes the valueOf() method available to the variable:

let y: boolean & { x: string } = Object.assign(true, { x: 'abc' })

console.log(y.valueOf()) // true
console.log(y.x)         // 'abc'

In contrast,

let y: void & { x: string } = ??? // what can be assigned to it?

y.x  // and what can be done with it?

@unional
Copy link
Contributor Author

unional commented Sep 12, 2023

btw, I understand that the assignment in the above examples is backwards.

The assignment logic is really void <= void & { x: string } which "make sense".

However, the problem I have is the last piece, that the type void & { x: string } (or in general void & X) does not manifest to anything meaningful, much like undefined & X and more so because void is a pure type-level construct.

That's why I argue that const f: () => void = () => ({ x: 'abc' }) is a special "unsounded" legacy rule dated before the existence of the unknown type.

Unless there are some actual meaningful use case or example of void & { x: string } other then (): void & { x: string } => ({ x: 'abc' })

@fatcerberus
Copy link

fatcerberus commented Sep 12, 2023

I don’t know if this has been linked yet but if not: #42709
void is just weird in general, but any changes around it would likely be in the direction of tackling #42709, based on past comments from maintainers.

In particular, this remark from there is in line with what you’re saying:

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.

@andrewbranch
Copy link
Member

The way I think of it is that void is like unknown with some special assignability restrictions when interacting with it directly, but those disappear and it acts more like unknown when it’s being compared across two return types in signatures. void & { x: string } has those same cannot-use-directly restrictions, but it behaves as expected when part of a signature:

[playground]

declare let s1: (() => void) & (() => { x: string })
declare let s2: () => void & { x: string };
declare let t: () => { x: string };

t = s1;
t = s2;
t().x;

Is it useful? Not that I can tell. Is it weird? Yes. But is it about as internally consistent as it can be given the weird rules? I think so.

@unional
Copy link
Contributor Author

unional commented Sep 12, 2023

@fatcerberus thanks for linking to #42709. It is very comprehensive. I think void has its purpose but the usage can be reduced. Will post a comment there.

@andrewbranch thank you for the example.

I am thinking that since void & { x: string } is not useful, does it worth keeping during resolution?

For example, unknown is eliminated during intersection resolution:
https://github.com/unional/type-plus/blob/main/type-plus/ts/unknown/unknown.spec.ts#L30-L51

by doing this, type-level do not need to perform any special handling of void/void & X which helps the "avoid the void" as suggested in #42709.

@andrewbranch
Copy link
Member

I would generally like to see void replaced with judicious use of either unknown or undefined, but while we have void, I think it makes sense to preserve it in intersections since it carries special behavior with it. I don’t see a strong reason to change it.

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Sep 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

6 participants