Using Generics in Typescript to Ensure Symmetry in Function Argument Values

Enforcing Dependencies & Correctness Across Function Argument Values At Compile Time

Using Generics in Typescript to Ensure Symmetry in Function Argument Values

Photo by Meiying Ng on Unsplash

Functions in JavaScript can take arguments and we can use Typescript to enforce a type on each argument, such that we would get build time errors if the function gets called with arguments of the wrong type.

What can be tricky and often elusive with Typescript is attempting to ensure that there is a dependency among the arguments, in a way that a second argument is somehow constrained by the value or type of the first argument.

This concept is what I am calling Function Argument Symmetry (FAS) and I am hoping that we can explore simple ways to achieve it by the end of this article.

Why Does It Matter

For the sake of conversation, lets assume we want to create a function that returns the string used to set the cache header for HTTP responses in an Express back-end application. One variant of this function can take two arguments with which it can then return the right cache string. The first argument can be the factor (number) and the second argument would represent how to interpret the factor's value (a union period ranges like "days", "weeks" e.t.c) such that there's some dependency between the values of the function's arguments.

Though the values passed into the cacheFor(...) function at lines 8 and 9 above are valid with regards to the declared types in the function definition, they don't quite make sense.

Should the caller be allowed to cache something for zero days? Would such a cache string even be valid? At line 9, did the caller mean to use 1 but then mistakenly passed in "weeks" (plural) alongside it? On the flip side, did they intend to cache something for "weeks" (plural) but erroneously pass in 1 as the first parameter?

Sometimes we need the parameters of a function to obey some dependency rules in a way that ensures the values collectively make sense and have meaning. This is what I am describing as function argument symmetry.

Although we can check and raise runtime errors if a function is called with nonsensical values, the Typescript compiler is more than capable of flagging such call attempts at development time and this can be very empowering for various reasons:

  1. We can rely on such dev time errors from the compiler to prevent a new class of faults from getting into our programs at runtime.

  2. We can ship APIs (function or class method signatures) that deliver much better developer experience and futher ensure our code is used as intended.

  3. We can significantly improve the readability of client code (code that calls or uses our code) which goes a long way to make them more maintainable.

How Would This Work

Let's see how we might design some variants of a function that relies on Typescript to enforce function argument symmetry. The first variant is a multi-arg function and the second is a single-arg function whose argument is an object with multiple fields. The third is a function with a single string argument.

Across all the variants of our cache function, the goal is to ensure that the function is always called with a positive number that make sense for the corresponding singular or plural form of the cache duration types (e.g "day" or "days").

To make the most of this exploration, I strongly recommend you review Matt Pocock's Video on Generic Types and Generic Functions.

Variant One - Multi-arg Function

To begin, we need to split the current duration type into separate types that represent the singular and plural forms so that we can use them in specific places where only that form is required.

// type Duration = "hour" | "hours" | "day" | "days" | "week" | "weeks";
type SingularDuration = 'day' | 'hour' | 'week';
type PluralDuration = `${SingularDuration}s`;

We then need to create a Duration generic type (always thank Matt for the disambiguation) that will use conditional logic to flag calls with 0 or less values.

type Duration<N extends number> = 
    `${N}` extends '0' | `-${string}`
     ? never
     : SingularDuration | PluralDuration

const cacheFor = <T extends number>(factor: T, period: Duration<T>) => {
    return `TODO: return string with ${factor} and ${period}`;
};

The Duration generic type expects a generic argument N that is constrained (has to be a number). We then enter a conditional logic that ensures Duration gets to be the union of SingularDuration and PluralDuration only if the stringified version of N is not zero or less then zero. Effectively, if N is a generic parameter that represents the type of the factor argument to the cacheFor function, then Duration uses it to know when to yell or to determine what a valid period type should be.

We need one more level of nested conditional logic to complete Duration and make it require the singular forms of "days" or "weeks" when necessary.

type Duration<N extends number> = 
    `${N}` extends '0' | `-${string}`
     ? never
     : `${N}` extends '1'
        ? SingularDuration
        : PluralDuration ;

and this reads as

type Duration<N extends number> = 
    if (`${N}` extends ('0' | `-${string}`))
        return never // yell
    else if (`${N}` extends '1')
        return SingularDuration
    else return PluralDuration ;

With these in place, our cacheFor function now has argument symmetry and the Typescript compiler will raise alarm where needed. Duration is a generic type that uses conditional logic to set the type for the period argument in the cacheFor function to either SingularDuration or PluralDuration, depending on what the value of the factor argument is.

When you understand what we've done here, you will easily grasp how we solve the next variants of the cacheFor function because the logic is basically the same so I won't be repeating anything we've already covered here.

Variant Two - Function With Single Object Argument

If the function were to take a single object argument that have a number fields, how might we prevent nonsensical values in the fields?

type SingularDuration = 'day' | 'hour' | 'week';
type PluralDuration = `${SingularDuration}s`;

type CacheConfig = {
    factor: number;
    period: SingularDuration | PluralDuration;
} 

const cacheFor = (config: CacheConfig) => {
    return `TODO: return string with ${config.factor} and ${config.period}`;
};

cacheFor({factor: -1, period: 'days'});       
cacheFor({factor: 0, period: 'hours'});       
cacheFor({factor: 1, period: 'weeks'});       

cacheFor({factor: 1, period: 'week'});        
cacheFor({factor: 5, period: 'hours'});

The config argument of cacheFor needs to match the CacheConfig type which requires a factor and a period property. This is standard Typescript code which does not prevent attemps to cache stuff for zero or 1 "weeks" (plural). Lets fix it with what we've already covered under variant one above.

type CacheConfig<N extends number> =
    `${N}` extends '0' | `-${string}`
    ? never
    : `${N}` extends '1'
        ? { factor: N, period: SingularDuration }
        : { factor: N, period: PluralDuration };

const cacheFor = <T extends number>(config: CacheConfig<T>) => {
    return `TODO: return string with ${config.factor} and ${config.period}`;
};

Resulting in the below experience at development time

Variant Three - Function With Single String Argument

If the function were to take a single string argument where different portions of the string represent the cache factor and the duration type, we might have set it up like this:

type SingularDuration = 'day' | 'hour' | 'week';
type PluralDuration = `${SingularDuration}s`;

type DurationSpecifier = `${number} ${PluralDuration | SingularDuration}`;

const cacheFor = (spec: DurationSpecifier) => {
    return `TODO: return cache string with: ${spec}`;
};

cacheFor('3 hour');      
cacheFor('1 hours');
cacheFor('0 weeks');

cacheFor('1 week'); 
cacheFor('5 hours');

We can then refactor the DurationSpecifier type and the cacheFor function signature like below to prevent nonsensical string values from getting passed into the function :

type DurationSpecifier<N extends number> =
    `${N}` extends '0' | `-${string}`
    ? never
    : `${N}` extends '1'
        ? `${N} ${SingularDuration}`
        : `${N} ${PluralDuration}`;

const cacheFor = <T extends number>(spec: DurationSpecifier<T>) => {
    return `TODO: return cache string with: ${spec}`;
};

Resulting in the below experience at development time

Conclusion, Takeaways & Further Reading

The Typescript compiler is truly powerful. While most developers confine its use to the obvious things it can do at development time, there are an entire class of potential runtime bugs it can spot that we often don't talk about.

Leaning more into generic types, generic functions and conditional types in Typescript will open more doors of possibilities and value for the quality of code we write and the overall experience of using APIs of the code we've shipped.

Further Reading

  1. totaltypescript.com/no-such-thing-as-a-gene..
  2. youtube.com/watch?v=le5ciL1T7Hk
  3. reddit.com/r/typescript/comments/10qok6z/te..