Beware the leaking any

A short introduction to the type system

Any is the top type in TypeScript's type system (while never would be the bottom type). Think about the types as a big tree, where each child type "extends" its parent, but not the other way around. This is very convenient when you have an object hierarchy, like Vehicle -> Car, because every Car is a Vehicle, but not every Vehicle is a Car.

It does however also work on a much simpler level, for example with the string type and a string literal type. Every string literal is a sub-type of the type string:

let parent: string = 'hello'
let child: 'hello' = 'hello'

Here, child would also "extend" parent, even though we don't really have a typical inheritance. That's why it's often easier to replace "extends" with "is assignable to" when thinking about types.

child is assignable to parent, but parent is not assignable to child.

Parent is not assignable to child because it's type is wider. This can be proven by trying to actually assign the variables to each other:

let parent: string = 'hello'
let child: 'hello' = 'hello'

// ✅ ok, as parent is the "wider" type
parent = child
// 🚨 Type 'string' is not assignable to type '"hello"'.(2322)
child = parent

We can assign child to parent, because child is assignable to parent, but it doesn't work the other way around.

So what about any?

In any case (pun intended), any would sit at the top of the tree. Everything is assignable to any. If we add any to the above example, our tree would be any -> string -> 'hello'

let top: any = 'hello'
let parent: string = 'hello'
let child: 'hello' = 'hello'

// ✅ ok, as parent is the "wider" type
parent = child
// ✅ also fine
top = parent

So far so good, and if any sits at the top, it must mean that you cannot assign it to a more narrow type, right? This is where things get weird with any:

let top: any = 'hello'
let parent: string = 'hello'
let child: 'hello' = 'hello'

// 🚨 Type 'string' is not assignable to type '"hello"'.(2322)
child = parent
// 🤯 no type error here
parent = top

Any is an exception to this rule, because assignments work both ways, which make any an escape hatch for the compiler. You can literally do anything with it, even things that clearly won't work.

Unknown to the rescue

In TypeScript 3.0, the unknown top type was introduced to fix this. It is like the type-safe big brother to any. If we replace any with unknown, we get the exact behavior we thought any would give us.

Anything is assignable to unknown, but unknown isn’t assignable to anything but itself and any

let top: unknown = 'hello'
let parent: string = 'hello'
let child: 'hello' = 'hello'

// ✅ ok, as parent is the "wider" type
parent = child
// ✅ also fine
top = parent
// 🚨 Type 'string' is not assignable to type '"hello"'.(2322)
child = parent
// 🚨 Type 'unknown' is not assignable to type 'string'.(2322)
parent = top

This is great, because now we have our real tree structure back with unknown sitting at the top, but it also means it's virtually impossible to do anything meaningful with unknown.

But that's okay.

Because we don't know what it is, we have to find that out at runtime first. TypeScript will narrow the type if we perform a type narrowing check:

let top: unknown = 'hello'
let parent: string = 'hello'

if (typeof top === 'string') {
  // ✅ top is of type string now, so it's assignable to parent
  parent = top
}

There are many ways to narrow types in Typescript, like using typeof, instanceof, the in operator, checks like Array.isArray or even user defined type guards. Working this way is a much safer approach because it tries to leverage the compiler, not bypass it.

When any is leaking

Okay, we've probably all used any from time to time to shut up the compiler, and that's not a problem. There are definitely diminishing returns when trying to go towards 100% type safety, and sometimes, it's just easier for everyone's sanity to disable the compiler via any and write a bunch of unit tests to make sure you don't screw up along the line.

Any becomes problematic when the scope is large, because it will disable the compiler in places you didn't think about. Let's have another look at what the TypeScript docs have to say about any:

When a value is of type any, you can access any properties of it (which will in turn be of type any), call it like a function, assign it to (or from) a value of any type, or pretty much anything else that’s syntactically legal.

This basically means if you have an any, and you call a function on it, the result will also be any. Every property will be any. Every function you return it from will then return any. If you use the return value of this function in a computation, the result will also be any.

All of a sudden, this little any is spreading like wildfire:

const dangerous: any = 5
// ✅ inferred to the number literal 5
const okay = 5

// 🚨 result is now `any`
const result = dangerous + okay

const dangerous2: any = { title: 'foo' }
const props = { hello: 'world' } as const

// 🚨 result2 is now `any` as well
const result2 = {
  ...dangerous2,
  ...props,
} as const

Especially the object merging took me by surprise, but it makes sense. You can't build a union type with any. Not even the awesome const assertion will help you here. This is especially dangerous when using it together with React components, as spreading the result of a function that returns any will make all props of that component fall back to any:

declare function myAnyUtil(input: Record<string, unknown>): any

function App(props: Props) {
  // ❗️ no other prop is type checked anymore
  return (
    <button onClick="yes please" {...myAnyUtil(props)}>
      click me
    </button>
  )
}

Oops. Because we spread the result of myAnyUtil, which returns any, onto our button, nothing is now type checked (if you are wondering: onClick needs to accept a function, not a string). Remember, jsx is just syntatic sugar for React.createElement, so the above code reads:

declare function myAnyUtil(input: Record<string, unknown>): any

function App(props: Props) {
  return React.createElement(
    'button',
    { onClick: 'yes please', ...myAnyUtil(props) },
    'click me'
  )
}

Now we can clearly see that the props object we pass to our button is widened to any, similar to the contrived example above, which is why the onClick prop is also not type-checked.

I believe this to be very dangerous, as its quite hidden. We rely on TypeScript to help us when refactoring, e.g. when altering union types. If I remove the 'secondary' variant of my Button component, and TypeScript wouldn't yell at me for all the existing usages, I would be lost in a larger code base.

But with a leaking any on my component, TypeScript would just stay silent. It becomes as useful as a unit test where you forgot to assert anything. It's even worse than plain JavaScript, because you think you're safe - but you're not.

When can this happen?

I believe it happens more often than you might think, especially if:

  • You're calling JavaScript from TypeScript - such functions will very likely just return any.
  • You are using a 3rd party library that has weak types (lodash.get for example).
  • You don't annotate your util functions with explicit return values and leak an any from them.

With any, you can do anything, but everything is better than any.

— TkDodo

The best advice I can give for situations where you have to use any is to keep it confined to a very small scope to avoid it from leaking. You can also statically analyze your type-coverage to get informed about places where any is lurking around. If the coverage decreases on a PR, you might have a problem. Further, avoid 3rd party libraries that are written in JavaScript unless they have very good types. Lastly, ensuring that your own util functions don't leak anything can be achieved by explicitly enforcing return types on them, even though I also like to utilize type inference as much as possible. This is certainly a trade-off you'd need to be willing to make.

That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below ⬇️

24