loading...
Cover image for Dynamically Check Function Parameters Before Runtime
Hemaka.com

Dynamically Check Function Parameters Before Runtime

babak profile image Babak ・4 min read

Prelude

Thanks to TypeScript's conditionals and self-referencing types; it is possible to instruct the compiler to make dynamic choices. This is what I mean by "dynamic"--of course, TypeScript is still a static compile-time type checker.

Introduction

In TypeScript, it is possible to restrict the value that can be assigned to a generic using extends however, there are limitations. In this article we will see how the we can use Type Aliases and the intersection operator & to enhance the kinds of limitations we can place on generics

On Generics

In TypeScript, generics enable us to express polymorphic functions, objects that deal with a variety of types, the return value of a function, and more. One could think of a generic as essentially a variable or a placeholder that is assigned to, so long as its not being widened in the process. By default, generics accept type {}, which is pretty much anything in JavaScript. For example:

declare function id<A>( a: A ) : A

Anything we provide variable a is going to be the type of generic A. But we can also restrict a generic before an assignment. Consider this map function:

declare function map<A, B, FN extends (a:A) => B>( xs: A[], fn: FN ) : B[]

The compiler will expect FN to be a function that takes an A to B. A and B can be anything; but FN has to be a function with an arity of one. This is because FN is already narrowed down to be a function of the given structure through the use of extends.

But there are limits to what we can do. What if, for example, we want to construct a merge function that statically checks that neither of two objects share any keys?

We might start by creating a Type Alias that returns shared keys between two objects:

type CommonKeys< A, B > = Extract<keyof A, keyof B> 

But we can't do this:

type Validate<A,B> = CommonKeys<A,B> extends never ? B : never
// 🚫 Error, B has a circular constraint
declare function safeMerge< A, B extends Validate<A,B>>( 
  objA: A, 
  objB: B 
): A & B

B cannot reference itself when declared; and conditionals are not allowed in declarations anyway. So how do we go about this?

Intersections

To solve this problem, we observe that the intersection of any type with itself is itself; and with never it is always never. As such, we first assign the generic and then validate using an intersection. Let see how we can use this to construct a merge function that requires both objects to have unique keys:

type CommonKeys< A, B > = Extract< keyof A, keyof B > 
declare function safeMerge< A, B >( 
  objA: A, 
  objB: B & ( 
    CommonKeys<A,B> extends never 
      ? B // B & B is B
      : never // B & never is never
  ) 
): A & B

Does it work?

// 🚫 Error
const mergedObj = safeMerge( { a: 1 }, { a : 2, b: 3 } )

Good, we get a compiler error! Both objects overlap on key 'a', which we don't want to allow.

// ✅ Works!
const mergedObj = safeMerge( { a: 1 }, { b : 2 } )

It works! Neither object shares a key and our merge will not overwrite any key-value pairs.

The Validation Type Alias as a Pattern

This bit of type code requires us to think about what is happening here:

 B & ( 
    CommonKeys<A,B> extends never 
      ? B // B & B is B
      : never // B & never is never
  ) 

We can make this a bit more concise using three helpers:

type IsNever<T> = [T] extends [never] ? 1 : 0;
type WhenNever<Evaluate,Return> = IsNever<Evaluate> extends 1 ? Return : never
type WhenNotNever<Evaluate,Return> = IsNever<Evaluate> extends 1 ? never : Return

If you're curious as to why IsNever puts T inside of an array, it is to prevent union distribution. We want to see if T is never and not if its union may contain never. We'll use this test to determine if want to raise a compiler error or not.

For merge function, we'll want to create an error when A and B share keys. We don't want to allow keys to be overwritten. No common keys will result in never, which is what we want. So we'll construct our type helper as follows:

/**
 * Validates that keys in object A do not overlap with object B
 * return A if true and never if not.
 */
type ValidateKeysDontOverlap<A,B> = WhenNever< CommonKeys<A,B>, A >

We can tuck this away into a type validation library and follow a convention where all validating types start with Validate. Then we can use it like this:

declare function safeMerge< A, B >( 
  objA: A, 
  objB: B & ValidateKeysDontOverlap<B,A>
): A & B

A Validation Type Standard

It could be useful if this pattern were standardized around some convention. My personal thought at the moment is that a validation type should return the first parameter if valid and never if not. In this order because TypeScript type aliases can have optional generics, for example:

type Example<A,B = A> = ....

Here the second generic B will be set to A, if not passed. So at least the first parameter will be required. However, as far as I know, not a lot of people are talking about this yet and the range of use cases known to me are limited to my own.

Conclusion

TypeScript enables us to narrow down the range of what a generic can be within some limitations. We can overcome those limitations by intersecting a generic with a Type Alias which will statically compute complex situations and return the same type we are intersecting with or never if we want to cause the compiler to complain about the result of our static analysis.

To better communicate what we are doing, it might be useful to adopt some standard for this pattern. A type alias intended for this kind of pattern might start with the name Validate and return either the first parameter it requires or a type never otherwise.

Posted on by:

babak profile

Babak

@babak

Twitter @babakness https://twitter.com/babakness

Hemaka.com

Hemaka is a full-stack technology consultancy focused on building compelling web-applications. Visit us at https://hemaka.com

Discussion

pic
Editor guide