Guarding against any-typed values in TypeScript
Typescript has improved my web app developemnt life signinificantly for several years already. During this time I’ve gradually learned to think in terms of structural types and to use the type system in ways that provide helpful guarantees about the behavior of the application code.
In addition to the strict typing features, TS offers several escape hatches for writing less safe code – even in strict mode. The most obvious one of these is the any
type that is assignable to all types available. While the strict compiler setting reduces the chances of accidental any
types, there are several ways to end up with such types inhabiting your scope. These include values defined in 3rd party libraries (or missing type declarations of those altogether), type inference problems and explicit declarations of variables with type any
. A single any
typed value can leak into other parts of the code and cause a lot of harm. Hidden any
values can additionally result in a false sense of security as the compile-time checks pass.
For some time I’ve been thinking if it would be possible to prevent the usage of an any
typed variable somewhere in the codebase at compile time. This could be useful in improving the guarantees of critical parts of the codebase and to prevent future modifications of the (potentially large) codebase from degrading the type behavior. I finally decided to take a stab at the problem and found a decent solution.
Let’s first look at the problem in code. Our critical function shall be the following
function inc(x: number) {
return x + 1;
}
We would like to ensure that no unwanted types of values are passed to it. The static typing ensures that a variable of type string
, boolean
or something incompatible with number
can not be passed. How about an any
type value?
let a = 1;
let b = "two";
let c: any = "three";
inc(a); // OK
inc(b); // Compile-time error
inc(c); // OK !
An any
-typed variable is generally assignable to anything and can thus be passed as a parameter to the function expecting a number
. We would like to somehow prevent this from occuring using compile-time type checks. My first stab at the problem (which turned out to be a decent one) was the use of generics and conditional types. The first – somewhat working – attempt was the following type
type A = <T>(a: T) => typeof a extends number ? number : void;
(I’ll use types A
, B
, C
, etc. to represent the iterations of the solution type definition as they evolve.)
Type A
is a conditinal function type, the return value of which depends on the given argument type T
. If the type parameter T = number
then the first branch of the conditional, i.e. number
, is the correct return type. If the type parameter T = any
, the conditional is ambiguous with either of the cases being possible. Thus the return type of the function is a union (or sum) of the two cases number | void
.
Now, if we declare a function of the type we can observe the initial results
declare const assert: A;
assert(a); // type: number
assert(b); // TypeError
assert(c); // type: number | void
We now obtain a concrete difference between the behavior between the desired type number
and the unwanted type any
. How can we use this to our benefit? Like this:
inc(assert(a)); // OK
inc(assert(b)); // TypeError
Through the conditional type + generic hack we’ve now prevented an any
value from being passed as a number! Our next step would be to generalize the assert
function signature to work for types other than number
. This requires little additional work
type B<T> = <U>(a: U) => typeof a extends T ? T : void;
declare const assertBoolean: B<boolean>;
Another improvement would be replacing void
in the conditional type with a better suited type. void
can get in the way if the type we’re expecing includes undefined
and null
for one reason or another. An ideal type would be something that is never correct. The bottom type never
might initially seem like the logical option but doesn’t work since the union X | never
can be substituted with X
. Instead, let’s use a unique symbol
.
declare const neverSymbol: unique symbol;
type C<T> = <U>(a: U) => typeof a extends T ? T : typeof neverSymbol;
The type typeof neverSymbol
can never be instantiated since there is no initial instance of it. This is achieved by using declare
. An additional benefit is that this operation is entirely type-level and emits no code to the compiled Javascript.
Speaking of runtime and compile-time, what about the declaration of the assert function? That’s no good, since we’re missing the runtime instance of the function we are calling. We unfortunately need a runtime representation for assert although we don’t really want it to do anything at runtime. The identity function is the minimal runtime representation we’ll use
declare const neverSymbol: unique symbol;
type D<T> = <U>(a: U) => typeof a extends T ? T : typeof neverSymbol;
const assertNumber: D<number> = (x) => x as any;
The runtime representation can be provided per type to be checked. The any
cast in the implementation is required to not have the compiler reject the declaration because of the unique symbol
branch of the conditional type. Let’s put it all together and see the compiled JS.
const num = 1;
const nonNum: any = "two";
function inc(x: number) {
return x + 1;
}
declare const neverSymbol: unique symbol;
type Assert<T> = <U>(a: U) => typeof a extends T ? T : typeof neverSymbol;
const assertNumber: Assert<number> = (x) => x as any;
inc(assertNumber(num)); // OK
inc(assertNumber(nonNum)); // TypeError (compile-time)
Using Typescript 3.9.2, this compiles to
"use strict";
const num = 1;
const nonNum = "two";
function inc(x) {
return x + 1;
}
const assertNumber = (x) => x;
inc(assertNumber(num)); // OK
inc(assertNumber(nonNum)); // TypeError (compile-time)
This is about as far as I’ve made it with the experiemnt. I see no way to remove the runtime addition of the identity function. Additionally, the assert functionality composes terrybly and doesn’t really work across function scopes or module boundaries. Wrapping the check in a function for instance loses the context since an any
typed variable passed to a function appears as the expected argument type inside the function body.
const guardedInc = ([x]: Parameters<typeof assertNumber>) =>
inc(assertNumber(x)); // TypeError
I have not used the approach in any real-life code but do believe that there might be cases there it could prove to be useful. As mentioned, the solution could be used to add compile-time safety to critical parts of a strongly typed Typescript program. To me, the issue is also interesting from a type-system perspective. I would love to be enforce even stricter rules for typechecking in Typescript and this is one small way to do it in userland.