Typescript: What's happening with `strictAny`?

Created on 7 Jun 2018  路  7Comments  路  Source: microsoft/TypeScript

After work on #24423, and discussion at #24593, we've come to the conclusion that we won't be adding a new --strictAny flag. Here's the rationale behind it.

Background

Recently a few of us met with the Hack team and discussed their proposed dynamic type which acts like any behaviorally but is as restrictive as our new unknown type with respect to assignability. In other words, you can dot off of dynamic, you can call/construct dynamic, etc., but you can't assign a dynamic to anything other than another dynamic type. This gives some of the ease-of-use of any, but ensures that that usage is strictly scoped, and escape has to be intentional.

Goal

We started with a few goals: to bring our users

  1. the functionality of dynamic (i.e. a scoped version of any)
  2. without introducing another concept (i.e. a new dynamic type)
  3. and something else that we thought more about after the fact (keep reading)

Rather than introduce a new concept, we opted to introduce a more restrictive mode for any that treated it as this dynamic type (i.e. --strictAny). This has the added benefit that users decide how strict any is rather than declaration authors choosing whether to use any or unknown or {} etc., and I believe that this became the third goal over time.

Problems

We experimented with --strictAny as a new flag in #24423 and the short story is that while the mode felt like it was catching a lot of questionable code, we started seeing a lot of code that quickly became tough to reason about.

Contravariance for any-ful signatures

One of the first things that popped up was that the type (...args: any[]) => any was no longer as flexible as before. Since the main change in --strictAny is to remove one direction of assignability of any with everything else, running code with --strictFunctionTypes meant that almost no functions were assignable to (...args: any[]) => any anymore.

Aside: why?

Think about assigning a value of type (x: number) => any to a binding of type (...args: any[]) => any. Because parameters are compared in the reverse direction in --strictFunctionTypes, we effectively try to see if any is assignable to number, and it wouldn't be under --strictAny!

One solution was to have users use a signature like (...args: never[]) => any instead (which has issues as we'll see). Instead, we special-cased this construct, though this didn't cover less-contrived signatures like (x: any, xs: any[]) => any.

Users need to know never

We've always told users that any is your escape hatch in TypeScript. Under --strictAny, you can still access any member on a value of type any - but you've lost the ability to say "just trust that I know what I'm doing" with it.

As mentioned on https://github.com/Microsoft/TypeScript/pull/24423#issuecomment-393753698

My feeling is that this behavior is what I want from any around 50% of the time. The other 50% of the time I want the current behavior, which is when I want to say "damn it, this type is going to be a huge pain, leave me alone". For example

declare function foo(x: Some & Very & Long<Type, Annotation | Have<Fun, Writing, This>>);

// Now an error!
foo({ /*...*/ } as any);

The above call to foo will no longer work, since any is no longer assignable to Some & Very & Long<Type, Annotation | Have<Fun, Writing, This>>.

The workaround is actually very simple - use never instead of any:

declare function foo(x: Some & Very & Long<Type, Annotation | Have<Fun, Writing, This>>);

// Always worked!
foo({ /*...*/ } as never);

But now you really have to know how both of these work, whereas I suspect most users have never been aware of never.

never, while often very useful, is something users have rarely had to think about. Now, it becomes the primary "just trust me" mechanism for type assertions, and that seems undesirable.

Places the compiler uses any for simplification

Today, the compiler "cheats" in a few places, substituting in any when diving into checks that may be very expensive. For example, when comparing signatures from a source type to a target type, if either side has more than one signature, all generic signatures will have their type parameters erased with any (see getErasedSignature in checker.ts).

But this indicates an assumption that we the language creators made about the laxness of any. And under --strictAny, code like the following fails because those assumptions have been invalidated.

   declare var x: {
       <T>(a: T): T;
       <T>(a: T): T;
   }

   declare var y: (a: number) => number;

   y = x;
// ~~~~~ Error, even though this is okay!

   x = y;
// ~~~~~ Uhh... but this one is definitely wrong and *doesn't* error.

So it seems questionable that users could expect this behavior change when the language itself couldn't.

Existing code and .d.ts files

Both lib.d.ts and DefinitelyTyped had breaks due to this. This hasn't always been a problem with other strictness flags, but it's something we can't ignore.

Didn't prove its worth for the cost

While I hate to be blunt, this is kind of the reality. The feature is useful, but maybe not quite enough given how it works today. I think @RyanCavanaugh summarized this best at https://github.com/Microsoft/TypeScript/issues/24711#issuecomment-394934222

Turning the flag on found some assumptions in the tsconfig parser that no one has really noticed (i.e. it looks like you will crash the compiler if the top-level tsconfig content is the literal string "null"), and a bunch of what I would consider noise. There was one instance of new Array that we didn't realize was making an any[]. Every other strict flag we've turned on has found at least one real bug (or at least a "true unsoundness" where something only happened to work) with reasonably minimal noise.

I would honestly hate for this flag to be turned on automatically in a codebase I was maintaining - I just wouldn't expect it to yield any value; teams that are "careful" about not introducing anys tend to be very successful at having that not happen, and are only using any where they really don't get value from the type system.

Is there any hope?

Alternatives we discussed include:

  • a new dynamic type

    • but we don't want another type

  • a mode where unknown becomes as lax as any in --strictAny (or even making that the default)

    • but now you're giving people a confusing top-type

We don't feel like these alternatives are significantly better (or necessarily as good). For that reason, we don't think we're going to pursue --strictAny in the near future.

Discussion

Most helpful comment

I would add that, today, expressions of type never really only occur in places where the type system has determined that the value truly cannot be observed unless some constraint of the type system was violated. The upshot of that is that we have never had to really wrestle with what it means to have never appear in a property position... but if asserting to never becomes the commonplace way to get out of a type error, then it's going to be in input positions, and if it's in input positions then it'll eventually be in output positions, and once you have a never in an output position you have to be real careful - foo<T>(x: T): T | string produces any if invoked foo(null as any) but produces string if invoked foo(null as never), and the latter is even more unsound than the any in the first place because it appears to provide a constraint that is definitely not guaranteed to be true.

Throwing ourselves into a world where never is the default cast is a) confusing as hell and b) just adds epicycles like "Add a --noImplicitNever switch to warn me when never values creep in to input positions unexpectedly".

All 7 comments

I would add that, today, expressions of type never really only occur in places where the type system has determined that the value truly cannot be observed unless some constraint of the type system was violated. The upshot of that is that we have never had to really wrestle with what it means to have never appear in a property position... but if asserting to never becomes the commonplace way to get out of a type error, then it's going to be in input positions, and if it's in input positions then it'll eventually be in output positions, and once you have a never in an output position you have to be real careful - foo<T>(x: T): T | string produces any if invoked foo(null as any) but produces string if invoked foo(null as never), and the latter is even more unsound than the any in the first place because it appears to provide a constraint that is definitely not guaranteed to be true.

Throwing ourselves into a world where never is the default cast is a) confusing as hell and b) just adds epicycles like "Add a --noImplicitNever switch to warn me when never values creep in to input positions unexpectedly".

It makes no sense. strictAny must be switched on.
Of course there will be problems with switching on but you should rather consider how to do it relatively smoothly.

We hadn't considered thinking about how to work through problems 馃

Of course, a great and good piece of work has already been done. But the good typing system should not allow for such strange behaviours.

Perhaps you should consider a section that allows you to "disable" strictAny for a block code?

The language "rust" has this nicely solved.
https://doc.rust-lang.org/book/second-edition/ch19-01-unsafe-rust.html

Just wanted to comment from the Google side. We both (1) eagerly turn on all the strictness flags you provide (we are glad to trade off development time for safety) and (2) use a linter to globally warn about using any, but finally (3) rely heavily on using any as a 'shut up type checker' sort of thing.

Whenever we turn on a strictness flag, we turn it on globally in our codebase, then sprinkle 'any' in all the currently-failing locations to make it easy for teams to individually fix their type errors. So I think this proposed strictAny would not be useful for us either, unless we had some other sort of escape hatch type. Preserving any while bringing in other mechanisms to move code away from any, like unknown, feels like the right approach to me too.

PS: we love reading these rationales, thanks for writing it!

@DanielRosenwasser this seems like a sensible conclusion given the design constraints for TypeScript. I'll share my perspective on a couple points that led us to a different choice in Hack.

Users need to know never

TypeScript uses any to shut up the type checker. For Hack we utilize error suppression comments instead. In fact there is no explicit way to write the any type within Hack, instead it is the type we use whenever type information is missing. So introducing a new type like dynamic is less costly since we don't have an explicit any type as well to support.

Didn't prove its worth for the cost

It is interesting your measure for if it was worth the cost is seeing if there are errors in existing code. The main motivation for us is to prevent future bugs from being introduced. In particular in a strict file for Hack, we've found developers have the expectation that the type checker will warn them when they are making mistakes. Next thing you know, they call a function that returns an any type (missing a return type) and they are unaware they no longer have protection from the type checker. When this occurs the team is asked the same question, "Why did Hack not warn me I was working with an any type?"

The dynamic feature was a response to this. Another approach we could've done is use the "strict" any in strict mode files. It is something we discussed (and may still do), but ultimately we saw enough value to make it it's own type in our type system.

Hey @dlreeves! From what you just posted, it definitely sounds like Hack had a good opportunity to introduce dynamic. Like you might've seen from our enthusiasm in person, we do think the feature is useful, especially for new code. But TypeScript has formed a community with direction and momentum for these scenarios. Convincing people to switch from any to dynamic might be a long-running challenge, and disrupting people's working code with --strict is what I alluded to by not paying off the cost for the approach we took. dynamic isn't off the table, but it's not something we're actively pursuing.

TypeScript uses any to shut up the type checker. For Hack we utilize error suppression comments instead. In fact there is no explicit way to write the any type within Hack, instead it is the type we use whenever type information is missing.

That's actually really interesting. So my understanding is that in Hack, any is in the type system, and has relatively similar semantics, but only arises implicitly (i.e. users can't use it explicitly), and implicit anys are disallowed in Hack's strict mode (which is similar to our noImplicitAny).

Funny enough, TypeScript does have a suppression comment: // @ts-ignore. It was partly motivated by some of the use-cases Google had (which @evmar alluded to above). If TypeScript had been designed at Google, maybe we'd have been using // @ts-ignore instead of any and have a similar story. 馃槃

For the record, we really enjoyed meeting with you and your team and hearing about the problems you were working on. The fact that some of the original work was inspired by Scala.js, and that we were inspired by your work, makes me happy that we can learn and collaborate.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

MartynasZilinskas picture MartynasZilinskas  路  3Comments

blendsdk picture blendsdk  路  3Comments

weswigham picture weswigham  路  3Comments

seanzer picture seanzer  路  3Comments

dlaberge picture dlaberge  路  3Comments