Using Generics in Typescript to Ensure Symmetry in Function Argument Values
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:
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.
We can ship APIs (function or class method signatures) that deliver much better developer experience and futher ensure our code is used as intended.
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
- https://www.totaltypescript.com/no-such-thing-as-a-generic
- https://www.youtube.com/watch?v=le5ciL1T7Hk
- https://www.reddit.com/r/typescript/comments/10qok6z/template_literal_types_are_crazy_powerful_can_be/
Subscribe to my newsletter
Read articles from Charles Opute Odili directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Charles Opute Odili
Charles Opute Odili
I am a Senior Software Engineer, Engineering Manager, and Mentor. I love building experiences that empower people at scale, helping businesses drive more value in measurable ways.