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

Question / Suggestion: Behaviour of unknown in distributive conditional type. #27418

Closed
3 of 4 tasks
jack-williams opened this issue Sep 28, 2018 · 8 comments
Closed
3 of 4 tasks
Labels
Question An issue which isn't directly actionable in code

Comments

@jack-williams
Copy link
Collaborator

This isn't a direct suggestion, more a query that includes a proposed alternate approach (though I'm not sure I would even want the alternate).

Search Terms

distributive conditional type unknown

Suggestion / Question

Here is the current behavior of a distributive conditional types applied to distinguished types never and unknown.

type F<T> = T extends number ? true : false;

type A = F<never>;    // never
type B = F<unknown>;  // false

A distributive conditional type maps over union elements, so the explanation for the first case has been described as:

A :: never is the empty union so we map over nothing, returning never.

Following this intuition one might assume the following:

B :: unknown is a infinite union (union of all types) so distributing always matches both sides.

Though this isn't how it actually works, and unknown is treated like a regular type and returns the false branch.

My question is: how would I go about explaining the current behavior in a way that is consistent with what never, unknown, and conditional types mean. To add to the confusion, any also has its own wildcard behavior that matches both branches (except when the extends type is any). Given than unknown has been describe as the type-safe counterpart of any, it seems like they should behave somewhat similarly in conditional types.

My suggestion / prompt for discussion is: what should unknown do? Are there any practical advantages to having it act a certain way? The only real alternate design is to have it distribute to both branches, but I'm not sure if that is 'better'.

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. new expression-level syntax)
@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Oct 1, 2018
@RyanCavanaugh
Copy link
Member

A :: never is the empty union so we map over nothing, returning never.

👍 👍 👍

B :: unknown is a infinite union (union of all types) so distributing always matches both sides.

Consider something more concrete like this:

type IsDog<T> = T extends Dog ? number : string;
type D = IsDog<Animal>;

Today D is string because Animals extends Dog is false. But by the proposed logic, this should be number | string because some Animals are Dogs.

That logic might seem like it's still OK, but for an arbitrary T and U where neither is a unit type, the type T & U conceivably does exist. It'd be really weird to say:

// M: number | string because Dog & Mortgage is not a contradictory type
type M = IsDog<Mortgage>;

If you think of types as Venn diagrams over all values (which I think is the most useful analogy), then it becomes more clear: it's "correct" to map over circles which are apparently disjointed, but it's not "correct" to carve up a contiguous circle into subpieces and distribute over them.

@jack-williams
Copy link
Collaborator Author

jack-williams commented Oct 2, 2018

But by the proposed logic, this should be number | string because some Animals are Dogs.

In my head this wasn't the proposed behavior. I think my reasoning boils down to top-down vs bottom up interpretation of unknown. I view unknown like an infinite union of all types, written like so:

type unknown = true | false | 0 | 1 | 2 .... | {x: true} | {x: false} ...

which is bottom up in the sense you construct up from smaller disjoint sets. Under this interpretation then a conditional type would distribute over each element, and because it includes every type you would always get both branches. I like this reading because it's very dual to never, which is basically:

type never = |

So my 'proposal' isn't really anything to do with 'plausibility' of assignment: "some Animals are Dogs"., but the fact that unknown is (under my interpretation) the actual union expression of all types and that's how conditional types work.

If I understand your view I think you're more top-down: you start with unknown as the set of everything, then carve up circles within that and say they are types. Under that approach then you wouldn't have that behavior.

Similarly with boolean. Some people might think of boolean as just 1 set with two values in, and to break them up would be odd. But TypeScript actually starts with true and false as individual sets and then combines them, consequently you get distribution over them. I view unknown abit like this.

So back to type D = IsDog<Animal>; I would say this gives string because Animal is not a constructed union, while unknown is (in spirit, if we could write it out). Though if animal was defined as:
type Animal = Dog | Cat, then you would get the proposed behavior.

I'm not actually sure if I want my proposal, I was curious to see how others viewed the behavior and whether distribution of unknown might make some things possible. I Appreciate the feedback! This can definitely be marked down as a question and not a suggestion.

@RyanCavanaugh
Copy link
Member

Your observation about boolean is instructive. The distributivity of it gives people a lot of grief precisely because it's not written as a union, so comes as a bit of a surprise.

There was a long internal discussion over whether or not we even needed unknown, because it has an identical domain to {} | null | undefined - it was even proposed to just write that as a type alias in lib.d.ts. But we explicitly wanted a type that didn't distribute over conditional types because that expansion typically made things worse rather than better - see #21316 (comment) (this comment is "hidden" by default; you'll need to expand the "see more" section a few times)

@jack-williams
Copy link
Collaborator Author

Your observation about boolean is instructive. The distributivity of it gives people a lot of grief precisely because it's not written as a union, so comes as a bit of a surprise.

Agree. I'd also add in never and any as giving people a surprise in some cases. My conjecture was that having a collection of 'special' types: never, any, and unknown, each with special behavior that is consistent in some way might be mutually beneficial in helping users get to grips with them. As it stands unknown is not special. After the discussion I don't really think I can back up my view on this.

There was a long internal discussion over whether or not we even needed ... (this comment is "hidden" by default; you'll need to expand the "see more" section a few times)

I can't really disagree with empirical experience that suggests the current behavior is just functionally better. It might be possible to argue for something useful useful it it was much easier to work with and understand, but that really isn't the case here.

Will close up this question to keep the issue tracker tidy.

@laughinghan
Copy link

This is a fascinating discussion. I actually didn't know that non-null primitive types were assignable to{}.

I think one of the key points of confusion upon diving deep into this area of TypeScript's type system is that primitive types are disjoint from each other, can be finite sets, and can have finite intersections; whereas object types are necessarily infinite sets which must overlap infinitely (i.e. have infinite intersections) or else be completely disjoint.

The Dog, Animal, Mortgage example is illustrative: @RyanCavanaugh's point only makes sense assuming that they're classes, which are (infinite) object types with infinite overlap/intersection. If they were primitives, for example:

type Dog = 'labrador' | 'husky' | 'pug' // ...
type Cat = 'persian' | 'siamese' | 'sphynx' // ...
type Animal = Dog | Cat
type Mortgage = 'my house' | 'your house' | 'the white house'

Then actually, @jack-williams' proposal would work great:

IsDog<Dog> = number
IsDog<Cat> = string
IsDog<Animal> = number | string
IsDog<Mortgage> = string

They don't even have to themselves be primitives for this to work, due to TypeScript's brilliant way of allowing you to combine primitive and object types into discriminated unions. @jack-williams' proposal would work equally well with something like:

class Dog {
  readonly type = 'dog';
  // ...
}
class Cat {
  readonly type = 'cat';
  // ...
}

type Animal = Dog | Cat

class Mortgage {
  readonly type = 'mortgage';
  // ...
}

But then you can't use traditional inheritance (class Dog extends Animal), you have to agree on the discriminant (type? _className? etc), and any object literals or objects from imported code that lacks the discriminant will have infinite overlap with all of these types and hence potentially distribute over both sides of the conditional. Which, fundamentally, violates what seems to me to be TypeScript's highest guiding principle, which is to work well with existing JavaScript idioms, code, and community.

@laughinghan
Copy link

The higher-level question I have is: is there a diagram or document somewhere that documents the relationship between the various kinds of types? (Not "kinds" in the type theory sense, I just mean, the various bunches of types.)

Something like:
Hasse diagram of every possible TypeScript type

@RyanCavanaugh
Copy link
Member

@laughinghan seems like it exists now that you've made it 😃

@laughinghan
Copy link

@RyanCavanaugh haha great! Yeah I went ahead and wrote up a little guide to all the types in the diagram: https://gist.github.com/laughinghan/31e02b3f3b79a4b1d58138beff1a2a89

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

3 participants