Repository navigation
Design Meeting Notes, 1/3/2024 #56942
Description
Intersections and Unions that Depend on Instantiation Order
type Union<A, B> = A | B
type UnionAny<T> = Union<T, any>
type UnionUnknown<T> = Union<unknown, T>
// these should all be the same type but are respectively: any, any, unknown
type UA0 = Union<unknown, any>
// ^?
// any
type UA1 = UnionAny<unknown>
// ^?
// any
type UA2 = UnionUnknown<any>
// ^?
// unknownany"eats" everything.unknowndoes too, except forany.- Problem is that we say
SomeUninstantiatedTypeVariable | unknown, we eagerly reduce that tounknown. - We would need to defer union reduction in the presence of generics and
unknownorany.- That would be very annoying - you wouldn't be able to say that unioning with the top type(s) just reduces to the top type.
- [[Editor note]]: probably also have to thread through contexts where you have to know when to reduce the type.
- Problem is that we say
- It certainly is an inconsistency, but did it come up in a practical context?
- That is unclear.
- Conclusion: we admit that it is inconsistent and undesirable, but we are not fully convinced that it needs to change.
Disallowing More Property Access Operations on never-Typed Expressions
-
From a purely type-theoretic standpoint, there is no issue with accessing a property on
never, the issue is arguably more that the code is unreachable. -
Could argue that you should say "this whole block is unreachable, so why do you have code?"
-
That is too heavy-handed - the following doesn't deserve an error, does it?
function foo(x: "a" | "b" | "c"): string { switch (x) { case "a": case "b": case "c": return "hello"; default: throw Debug.fail("Wrong value " + x); } }
-
Two common views of
neverare- "Consuming" a value of type
neverto enforce an exhaustiveness check. - "Probing" a value of type
neverto report a helpful error for a violation of the type system or usage from untyped code.
- "Consuming" a value of type
-
In practice, this change actually breaks a bunch of existing code.
-
Conclusion: feels like we don't have an appetite
Preserving Type Refinements Within Closures Past Their Last Assignments
-
Fixes lots of issues that get duped to Trade-offs in Control Flow Analysis #9998.
-
When a closure is created after all assignments to some closed-over variable, then you can effectively think of that variable as unchanging.
-
Effectively safe to preserve the type-assignments so long as the closure isn't hoisted.
- Means things like object methods, function expressions, and class expressions can observe prior type narrowing/refinment.
- In theory, class declarations could also be made to work here.
- We noticed that class declarations were considered "hoisted"...but they're not!
-
Note that we don't quite narrow in a lexical manner within "compound statements". We use the "end" of the compound statement as the source of the last assignment.
function f5(condition: boolean) { let x: number | undefined; if (condition) { x = 1; action(() => x /*number | undefined*/) } else { x = 2; action(() => x /*number | undefined*/) } // <- we consider this to be the position of the last assignment of 'x' action(() => x /*number*/) }
- This approach lends to a fast and efficient check.
- It would also be tricky in the case of loops, where the "last" assignment cannot truly be determined.
-
Variables that are referenced in closures past the last assignment do not necessarily trigger an implicit any warning anymore.
let x; x = 42; doSomething(() => x)); // ~ // Implicit any error! ❌ x = "hello!"; doSomething(() => x)); // No error! ✅
-
If we limit to
letand parameters, then we don't have any issues with our perf suite - but it does forvaron monaco.- We might have other analyses we can apply.
-
Function declarations in block scopes should maybe have captured variables appropriately narrowed.
function f(x: string | number) { if (typeof x === "number") { function g() { x // should this be number, but is not in the PR } return g; } }
Activity
fatcerberus commentedon Jan 4, 2024
It seems like when it comes to
never, about half of people expect any operation onneverto be an error (e.g. #56952), which makes some sense because it's indicative of unreachable code (assuming that nobody's lied to the type system), while the other half would be just as surprised to see it not act like the bottom type that it actually is.For what it's worth: If I speak with my type theory hat on, I would really prefer it to act as a proper bottom type (everything is permitted, ex falso quodlibet, etc.), but in actual practice that might make it act too much like an
anyfor comfort, so ultimately I think TS should keep being pragmatic about it. It's a tricky issue for sure.rotu commentedon Jan 5, 2024
neveris not the same as the bottom type, which is not the same as an empty type. I think it's a mistake to conflate these!true & (()=>1). You can't expect the type system to recognize every empty type as empty. You can also have higher-order empty types, e.g.<T,>()=>T.any value is assignable to this typethis type is assignable to any other type. Such a type is not guaranteed to exist --- for example, you might have a sort ofmustusetype that requires its value to be used instead of discarded.never= one particular type constant. It happens to be a bottom type and happens to be empty.I think there is room for carving up
neverfurther. For instance a type that represents "an expression which, when evaluated, results in a runtime exception" or "an expression which, when evaluated, never terminates".There's also the possibility of an "oopsies" type - a type that actually means "if any expression is deduced to be this type, then the programmer has made a mistake". I think that's something that should be introduced instead of having so many hardcoded errors in the type checker!
fatcerberus commentedon Jan 5, 2024
This is all off-topic, but well, you've successfully nerd-sniped me 😛
I suppose in an arbitrary category a bottom object doesn't necessarily have to be an empty one - but in
Set(which models most type systems pretty well, at least from the point of view of assignability), there's really only one bottom object - the empty set. In TS, that'snever, the type to which no values are assignable.That's the top type (
unknownin TS). The bottom type is the one that's assignable to everything else (because it's a subtype of all other types).In a language without typed exceptions, that distinction seems academic.
This is in fact something a lot of people would like; see #23689.
P.S. don't ever try to fit
anyinto a coherent type theory. It'll get you nowhere and give you a splitting headache 😉rotu commentedon Jan 5, 2024
I want to push back against the identification of types and sets. A type judgement means “if you have a value of this type, here’s what you know about it”.
In most type theories, you don’t have a unique empty type. In fact,
[never],[never, never], etc. are not and should not be considered the same asneverbecause:There is only one
bottom valueunique set of values of empty type but expressions are not values. And TS assigns types to expressions, not just values! It’s especially important when considering short-circuiting operators&&,||,??,?., etc.You’re right. I mistyped.
In a language that might want to include typed exceptions in the future, it’s certainly not :-)
fatcerberus commentedon Jan 5, 2024
Sure, types are not sets. I just meant that
Setis a useful (if not 100% accurate) model for assignability of types, not for type systems in general. Set theory and type theory are separate for a reason :)There are no bottom values. That's the whole point of having a bottom type - if you think you have such a value, the program must have already diverged (stuck in an infinite loop, terminated abnormally, threw an exception, etc.). That's explicitly why it can act as a subtype of everything - because the program diverged, the code with the bottom "value" will never execute so it can safely do anything it wants with it with no ill effect. It's the type theoretical analogue of the principle of explosion; the contradiction from which to "prove" anything arises from assuming the unreachable code is reachable.
When you start speaking at this level of abstraction, it's really difficult to say what things are1. We can only really say that
[never]or{ x: never }or whatever are isomorphic tonever, and even then only if we consider them in the context of set theory (i.e. they all map to the empty set). As types in the TS type system, they are certainly distinct; I can't argue with that.Anyway, all this theoretical talk is fun, but we maybe should stop clogging up a thread meant for more practical discussion of how to implement things with it. I'd recommend joining the TS Discord channel if you haven't already; then you can have discussions like this all day :)
Footnotes
"The riddle is solved when the pillars fall." ↩
rotu commentedon Jan 5, 2024
You caught me. That's what I get for not checking what I type :-). I meant "there is only one set of values of an empty type"
No. That's the point of having an empty type which is known to be empty and expressible in the type theory. A bottom type is a subtype of all types. Both
neverandanyare bottom types.Here's the practical bit. Ideally, the "intersection"/"union" should give the unique lower/upper bound of types. But the type operators
&/|can't hope to satisfy this because:Arguably:
existsover union means that a union of all sets of values is not equivalent to the upper limit of the union of all values, i.e.unknown.fatcerberus commentedon Jan 5, 2024
anyreally doesn’t count IMO because it’s more a flag to tell the type system “don’t typecheck this thing” than it is a proper type. The only way I could try to fit it into a theory is to say that it’s “deus ex machina: the type” but even that gives me a headache 😉Is there any type system in existence where the (strongly typed) “subtype of all types” bottom type isn’t also empty? AFAICT that always must be the case because subtype hierarcheries diverge - the intersection of “the set of all cats” with “the set of all giraffes” is already necessarily empty, and that’s just two types, let alone finding the common subtype of everything in existence.
Finding abstractions is fun, but at some point you do have to ground yourself back in reality to get any work done.
rotu commentedon Jan 5, 2024
I like to think of
anyas the "hold my beer" type :-).Kinda. C has
void *which is assignable to any pointer type. It is the bottom type of all pointer types. And it is inhabited by the null pointer.Most type systems don't have a bottom type e.g. you may have completely separate hierarchies for structs and for functions with no common subtype. Or e.g. there may not be a subtype relation at all!
You're right that if a type system (1) has some empty type and (2) has a bottom type, then (3) that bottom type be empty.
Of course you could use that argument to say "there is no need for a
nevertype because types describe values and there's no value you need it to describe". Or go a step further and say "types are erased at runtime, so there's no need for them!"The big picture problem as I see it is that so much of TypeScript is defined by its current implementation, not a language spec or some categorical characterization of how it should work. If two programmers disagree about what
nevermeans, then a type annotation involvingnevercannot be used to effectively communicate!fatcerberus commentedon Jan 5, 2024
Oh for sure, I didn't mean to imply we shouldn't have abstractions at all, just that at some point you do have to apply them, and from what I could tell the conversation was headed in the direction of pure head-in-the-clouds abstraction, so I wanted to bring things a bit more down to earth by putting it in context. Kind of a "have your head in the clouds but your feet on the ground" sort of deal.
Indeed, which is why I think stressing too much over abstractions like type theory, referential transparency, etc. is misguided, at least where TypeScript-the-compiler is concerned. TS used to have a specification, a long time ago, but has since outgrown it and the direction of the project is now primarily guided by pragmatism. In the (probably paraphrased) words of Ryan Cavanaugh (@RyanCavanaugh), "consistency is a proxy measure, not an end goal" for TypeScript.
By the way, you might find this interesting (or distressing, depending on your disposition w.r.t. applicability of type theory):
DanielRosenwasser commentedon Jan 5, 2024
I don't know if I feel like a distinction around (1) has weight as much as (2). It feels like I've definitely encountered the latter, but can't immediately come up with an example. Maybe "I promise never to call this constructor" sort of stuff with abstract classes
I'm coming back to this point because I've written lots of design meeting notes where we end up claiming that "object types containing
nevershould producenevers", but we only avoid doing so because it might break other assumptions in the type system (e.g. instantiation of type variables in an object type always produces an object type), or it might be expensive/unreasonable in practice (e.g. discoveringnevers produced by intersection types requires eager exploration of each constituent's properties, which might require unbounded exploration to discover conflicts).fatcerberus commentedon Jan 5, 2024
Oh great, we nerd-sniped one of the maintainers now 😅
DanielRosenwasser commentedon Jan 5, 2024
Oh, also the UX is worse if you collapse a type containing
nevertonever. We had to special-case stuff like the following:DanielRosenwasser commentedon Jan 5, 2024
^ and actually, I think that's a great example of why we should give an error on
neverusage in some capacity - something went wrong upstream and you really need to know about it.rotu commentedon Jan 6, 2024
Theory vs praxis is definitely a balancing act, I'll give you that!
I do think referential transparency is critically important, not just academic. I don't expect the JavaScript to be referentially transparent, but I do expect generics to result in equivalent types, given equivalent input types and context. #56906 for example seems like unreasonable behavior because of this.
Here's an example where
extendsis not referentially transparent. As you can see,Z1andZ2both wind up callingEthe same way, but resolve to different types. This makes it hard to reason about whatE"means" and means that I have zero confidence my program will work the same way in future versions!:playground
fatcerberus commentedon Jan 6, 2024
I don’t know what more I can say re: referential transparency other than to repeat what I’ve heard from maintainers in the past (again, paraphrasing): “it’s expected that writing different code yields different results”. While RT would be nice to have, it’s probably not a fruitful pursuit, given that.
rotu commentedon Jan 6, 2024
No, it's expecting
four + 1 == 5whether you got the value asfour = 2+2orfour = 3+1. Different code can yield different results, but the same code operating on equal inputs should give equal results.fatcerberus commentedon Jan 6, 2024
Here's a good example of what I'm talking about: #51551
Quoth Ryan Cavanaugh (@RyanCavanaugh):
rotu commentedon Jan 6, 2024
That doesn't bother me so much. An implied type attributed to an untyped expression is subject to fuzzy judgement.
That's very different from a generic type expression. I do expect equational reasoning and compositionality to hold for closed-form type expressions, just as I expect for algebraic expressions.
I would maybe expect some wiggle room for
infer(which makes no promises about maximality).Conaclos commentedon Jan 10, 2024
typo:
s/2023/2024/[-]Design Meeting Notes, 1/3/2023[/-][+]Design Meeting Notes, 1/3/2024[/+]