TypeScript Advanced Usage – Template Types
13 min read • 2581 words
Table of Contents
- Introduction
- Example
- Same Example, in TypeScript
- Diving Deeper
- Template Types
- Unnecessary Examples
- Conclusion
Introduction
Audience
This is a technical post intended for software developers. If you don't code, you can skip it. If you are new to TypeScript, this post may be dense for you, but you may find value in reading it.
Overview
TypeScript is powerful and expressive. Typically we are able to reference the types we are using in our classes and functions directly. Sometimes we want to write a generic function that can work with multiple types, without losing type safety. This post helps explain how to do that.
This post starts with a vanilla JavaScript example and slowly builds it up to a full-fledged utility method that leverages type safety insofar as possible. Along the way, we spend a lot of time exploring alternative implementations to the example code, and caveats we may run into with those implementations.
It's not until the later half of the post that I even mention template types, but try to bear with me until I get there–a lot of the background leading up to it will help provide necessary context toward describing their usefulness.
Example
TypeScript is great, even in simple use cases. Lets start with a code snippet in plain-old JavaScript.
function databaseQueryWrapper(variables) {
return database.query(variables);
}
Simple enough, right? This function takes an argument: query variables, and returns the results of querying the database with those variables. But what is variables? What variables
do I need to pass to the query
method? This is something we can't answer without more context.
This is where TypeScript can help.
Same Example, in TypeScript
function databaseQueryWrapper(variables: QueryVariables): any {
return database.query(variables);
}
This is an improvement. By reading the same code, we can now infer: pass in QueryVariables
, get back anything. When starting out with TypeScript, this change alone enhances your codebase with type safety. But let's go deeper.
Diving Deeper
The above example is overly simplistic for illustration purposes. Let's talk about what it's doing. The method queries the database, which is a low level operation. In any language, when you query a database, you pass in a query and get back a list of... <the thing that you queried for>. In the example above, we typed the return to any
, which is less than ideal.
Avoid any Like the Plag... ahem Coronavirus
I am about to spend a loooong time convincing you that any
, used in the above examples is dangerous and ought to be avoided.
Why any?
Typing the method's return value to any
, (equivalent to bypassing TypeScript entirely), while problematic, has a single benefit: we can proceed with writing typed code where we consume databaseQueryWrapper
. See:
// query the database for a list of contacts
const variables: QueryVariables = {...};
const queryResults: Contact[] = databaseQueryWrapper(variables);
If you don't explicitly use they type any
in your code, any time you are working with vanilla (non TypeScript) JavaScript, all function arguments and return values are assumed to be any
. The caveats I discuss below are worth understanding because as you convert your codebase over to stronger typings, you will run into issues of this nature.
We will explore our options for typing the return value to something other than any
later in this post.
With any, Type Safety is Lost
TypeScript will allow us to assign the any
type returned from databaseQueryWrapper
to the Contact[]
type in the above example. But consider the following issue one could inadvertently introduce during development:
// oops, we forgot the `[]` in our queryResults type
const queryResults: Contact = databaseQueryWrapper(variables);
TypeScript is happy, but if we try to work with the queryResults: Contact
variable from the above example:
console.log(queryResults.address);
// undefined
console.log(queryResults.address.city)
// throws Cannot read property address of undefined
The first line of code logs undefined
because we are referencing queryResults.address
when we should be referencing queryResults[<array index>].address
. The second line throws an error because we (both the developer, and TypeScript) think we are accessing the city
property on the address
object of a Contact
, but in reality we are attempting to reference the city
property of what the previous line of code already let us know... is undefined
.
Improvement #1 (Still using any)
If we know database.query
will always return an array, we can make an enhancement and return any[]
instead of any
to improve type safety:
function databaseQueryWrapper(variables: QueryVariables): any[] {
return database.query(variables);
}
If we try to use it, we get an error before the code even compiles:
const queryResults: Contact = databaseQueryWrapper(variables);
Type 'Contact[]' is not assignable to type 'Contact'.
That's whats up. While this is progress, it still requires us to use any
. any
is evil. So we are clear: If you eliminate no other TypeScript error from your codebase, eliminate this one:
Type declaration of 'any' loses type-safety. Consider replacing it with a more precise type. (no-any)tslint(1).
Improvement #2 (Using unknown in place of any)
How do we get rid of that any
? Let's try typing the return to unknown[]
:
function databaseQueryWrapper(variables: QueryVariables): unknown[] {
return database.query(variables);
}
...but when we try to use it:
const queryResults: Contact[] = databaseQueryWrapper(variables);
Type 'unknown[]' is not assignable to type 'Contact[]'. Type 'unknown' is not assignable to type 'Contact'.ts(2322)
To bypass this, we have to cast it:
const queryResults: Contact[] = databaseQueryWrapper(variables) as Contact[];
Issues with unknown and Casting
We got that any
out of the way, but can we do better? This solution requires us to cast the return value of databaseQueryWrapper
everywhere that it is used, fuck that. Casting is sometimes necessary, but designing an API that requires it is a terrible starting point. What if you work with "cast happy" TypeScript developers? You know the type, their attitude is usually: "Oh? It won't compile? That's alright, just cast a return value here... a variable over there... and boom... ok TypeScript is happy." How do you prevent people like this from shooting themselves in the foot?
Revisiting the example mistake from above, which now throws a type error:
// oops, we forgot the `[]` in our queryResults type
const queryResults: Contact = databaseQueryWrapper(variables);
Type 'Contact[]' is not assignable to type 'Contact'.
There are two ways someone might try to fix it.
Properly Casting
If you recognize your mistake, you'll fix the variable typing to include []
:
const queryResults: Contact[] = databaseQueryWrapper(variables);
Which will give the following type error:
Type 'unknown[]' is not assignable to type 'Contact[]'. Type 'unknown' is not assignable to type 'Contact'.ts(2322)
Which you will (correctly) fix with:
const queryResults: Contact[] = databaseQueryWrapper(variables) as Contact[];
Hastily Casting
If you don't recognize the mistake, and you're feelin' a lil cast happy, you'll trust your queryResults
variable's typing and cast the function return as your first fix:
const queryResults: Contact = databaseQueryWrapper(variables) as Contact;
Fortunately, TypeScript will throw an error in this case.
Conversion of type 'unknown[]' to type 'Contact' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.ts(2352)
This is progress. The last time we had this bug we didn't find out about it until runtime–now TypeScript is catching it for us at compile time. If you scrutinize the message, it helpfully says 'unknown[]' to type 'Contact'
, which should tip you off that your variable, which is typed to Contact
should actually be typed to Contact[]
. But who reads error messages amirite? (I do, and you should) Unfortunately, the second half of this error message is actively unhelpful. TypeScript encourages the cast happy approach when it advises "convert the expression to 'unknown' first". Someone following TypeScript's recommendation would be inclined to fix the errors thusly:
const queryResults: Contact = databaseQueryWrapper(variables) as unknown as Contact;
TypeScript is happy, but we have introduced the same bug we had from when our return was typed to any
:
console.log(queryResults.address);
// undefined
console.log(queryResults.address.city)
// throws Cannot read property address of undefined
The takeaway? Casting introduces bugs in the same class as bugs you had when your codebase was full of any
types. Be smart about when you use it, if at all, and definitely don't require it when designing your API.
Template Types
Avoiding any and casting with Template Types
Can we stop here? Should we even worry about the "cast happy" developer who might shoot themselves in the foot while consuming our API? Probably not, who do we think we are, God? If "cast happiness" is a consistent issue with any one person on the team, let them know like "hey, types are cool, honor them and stop casting shit left and right". Don't waste your own time writing code to protect people from themselves.
But, I will continue for two reasons:
- On our bad days, we are all that "cast happy" developer. As engineers we are placed under deadlines and pressure all the time. All engineers are gonna cut corners–it comes with the territory. Good engineers cut the right corners at the right time (and document the fact that they cut the corner, why they cut the corner, and the potential impact of their decision to cut the corner, but I digress)
- I haven't even mentioned template types yet in this incredibly long winded and verbose TypeScript example. I can't end the blog post before I discuss template types. And this is still a useful illustration for doing that.
Are we convinced template types might be useful yet? Cool. Let's use them.
Query Example, in TypeScript Using Template Types
function databaseQueryWrapper<T>(variables: QueryVariables): T {
return database.query(variables);
}
const queryResults: Contact[] = databaseQueryWrapper<Contact>(variables);
While the consuming code is slightly less intuitive, and requires a shallow understanding of template types, it eliminates casting, removes the any
keyword, and is less error prone. This is great.
Now let's type the variables argument...
Up until now, we have used a vague QueryVariables
type for the variables
argument for purposes of simplicity. But in practice, what is variables going to be? This example was inspired by a wrapper that I wrote for apollo-client which we use to query our GraphQl API at my job. In my case, the variables that we use to query for an entity depends on the entity that we are querying for, so what we are able to pass to the query method needs to change based on what we are expecting to get back. Just like we used template types to allow the consumer of databaseQueryWrapper
to specify its own return type, we can use template types to allow the consumer to specify the type for the parameters it wants to pass to the method:
function databaseQueryWrapper<T, U>(variables: T): U {
return database.query(variables);
}
const variables: ContactQueryParams = {};
const queryResults: Contact[] = databaseQueryWrapper<ContactQueryParams, Contact>(variables);
Now we are really flying. We have implemented a great low level helper that helps us query our database. We can define some helpers for our consumers to use:
export function queryForContacts(variables: ContactQueryParams): Contact[] {
return databaseQueryWrapper<ContactQueryParams, Contact>(variables);
}
export function queryForEvents(variables: EventsQueryParams): Event[] {
return databaseQueryWrapper<EventsQueryParams, Event>(variables);
}
What the Hell is up With the T and U?
I was trained in C++, which has the same exact concept of template types as TypeScript. To my knowledge, convention has always used letters of the alphabet, starting with T (I assume for "Template") when naming template types.
What most examples on the internet never tell you, is that T
and U
are just variables, and can be named anything. Readers of these internet tutorials would be better served if descriptive names for T
and U
were used. You are advised to never name variables stuff like a
and b
, so why doesn't the same thing apply to template variable names? I think everyone is afraid the programming gods will strike them down if they break convention here, but imma do it anyway:
function databaseQueryWrapper<TypeForQueryVariables, TypeForQueryResults>(variables: TypeForQueryVariables): TypeForQueryResults {
return database.query(variables);
}
This example removes the ambiguity from T
and U
and captures some of the original developer's intent when creating the templatized method to begin with.
At the risk of talking out of my ass: Once you are so far off the deep end that you are finding value in using template types within your functions or class implementation, you won't be bothered by the fact that your templatized types have super abstract names like T
and U
. You're already thinking so far in the abstract it will be the least of your concerns. I would venture to say that most examples don't bother giving T
and U
more meaningful names because once this whole concept clicks for you, T
and U
will prove to me more than sufficient descriptors.
Unnecessary Examples
If I hadn't already convinced you of the value in avoiding casting and unknown types as reason enough to dive into template types, let me give you two more concrete and less contrived examples of when you may need them.
Normalizing a Crappy database.query Method
What if the database.query
method is inconsistent and returns a single object if the query only returned a single entity, but an array of objects if the query matched multiple entities? This is a low-level oddity that you might want to smooth over within databaseQueryWrapper
so that future consumers wouldn't need to check if the item(s) returned from databaseQueryWrapper
is an array or not.
Let's fix it:
Same Example, With Normalized Return Type
function databaseQueryWrapper<T, U>(variables: T): U[] {
const results: U | U[] = database.query(variables);
if (!Array.isArray(results)) {
results = [results];
}
return results;
}
Cool, you just saved the rest of your team from having to accidentally discover that your third-party database package sucks ass.
Same Example, Taken to an Unnecessary Level of Detail
What if your database.query
method might throw an error if the database is not initialized or properly authenticated, but returns an object containing an error if the query was malformed? Thats inconsistent (in this example, this particular inconsistency might be okay)! Maybe your database package doesn't return the number of entities that match your query, but you want to encapsulate that and return a count along side your results? tl;dr, what if you want to normalize your return value to the following:
{
success: boolean;
error?: string;
results: Contact[];
count: number;
}
This is something you would want to encapsulate within the databaseQueryWrapper
method, and template types can help:
interface DatabaseQueryResults<T> {
success: boolean;
error?: string;
results: T[];
count: number;
}
function databaseQueryWrapper<T, U>(variables: T): DatabaseQueryResults<U> {
let success: boolean = true;
let results: U | U[] = [];
let error: string = '';
try {
results = database.query(variables);
} catch(e) {
// don't follow my lead here, I am swallowing the error's
// stack in this example... don't be like me
error = e.message;
success = false;
}
const results: U | U[] = database.query(variables);
if (!Array.isArray(results)) {
results = [results];
}
return {
success,
results,
count: results.length,
error,
}
}
Conclusion
Thats it! I hope you found value in this post. Thanks for reading it.
Click for an article that I stumbled on while researching this post. It's a great read and full of TypeScript tips.