Useful Advanced Typescript Features
Table of Contents
This post covers a small collection of features and patterns I've found useful when working on TypeScript projects. Do note, what I've written is somewhat advanced, and isn't particularly useful for beginners.
Creating type safe utility functions
There are times when certain libraries or APIs don't have great type definitions, or unexpected types.
For example, you may have experienced the unpleasant suprise when Object.keys
returns a string[]
rather than the actual keys of the object (and this is the intended behaviour by the TypeScript authors).
When you do come across this situation, don't shy away from creating utility functions whose sole purpose is to get better types.
For the Object.keys
problem, I always create a type safe Object.keys
helper in my TypeScript projects, like this:
function getObjectKeys<Object extends Record<string, any>>( obj: Object ): Array<keyof Object> { return Object.keys(obj); }
Conditional types
Conditional types essentially allow you to check if a type A
, is equal to, or is a subset of another type B
.
You use it in the form A extends B ? <true-expression> : <false-expression>
.
One example of where I use this, is when I create a DeepPartial
utility type - the inbuilt Partial
type only
makes the fields at the first level of an object optional, however DeepPartial
makes every field at every level of the object optional:
type DeepPartial<Object extends Record<string, any>> = { [Property in keyof Object]?: Object[Property] extends Record<string, any> ? DeepPartial<Object[Property]> : Object[Property]; }; interface Person { name: { first: string last: string; } age: number } type PartialPerson = DeepPartial<Person>; // Results in: { name?: { first?: string last?: string; } age?: number }
- DeepPartial takes in a Generic parameter,
Object
, and we constrain it to be a plain JS object - We extract the keys in
Object
using:keyof Object
- We then iterate over every key using the operator
in
, and assign the key to the variableProperty
- We add a new field to the resulting object with an optional field:
[Property]?
- We check if the value at
Object[Property]
is another object using:Object[Property] extends Record<string, any> ?
- If it is an object, we recursively call
DeepPartial
on it - Else, we just return the value at
Object[Property]
Validation messages
Another place where I use conditional types, is to provide custom validation messages to arguments of a function.
I don't recommend you add this complexity to your application code, but this could be useful if you maintain a library.
type Validate< Arg, ExpectedType, ErrorMsg extends string > = Arg extends ExpectedType ? Arg : ErrorMsg; function sendMessage<Arg>( message: Validate<Arg, string, "The message must be of type 'string'"> ) {} // This won't compile! // Validate will return the specific type literal: // "The message must be of type 'string'" sendMessage(1); // compiles without issues: sendMessage("hello");
Renaming Fields of an Object
Let's say you want to create a new object, whose keys are the same as another object, but in uppercase.
You can use the as
keyword when iterating over fields using in
, to rename the existing fields:
const some_constants = { max_age: 100, minimum_age: 0, country_of_residence: "AU" }; type SomeConstantsInUppercase = { [Property in keyof typeof some_constants as Uppercase<Property>]: typeof some_constants[Property]; } // results in: { MAX_AGE: 100, MINIMUM_AGE: 0, COUNTRY_OF_RESIDENCE: "AU" }
Note: Uppercase
is another inbuilt TypeScript utility.
Using infer
infer
is something I confess to not understand very well. However I have found it useful when creating more complex types
and I find looking at some examples helpful in understanding how to use it.
infer
is used with conditional types in order to extract types in the condition and assign it to another generic variable.
The golden rule to remember when using infer
is:
infer
can only be used in theextends
clause of a conditional type.
Here's an example where I convert a string from kebab-case
to snake_case
:
type KebabToSnakeCase<S extends string> = S extends `${infer Char}${infer Rest}` ? Char extends "-" ? `_${KebabToSnakeCase<Rest>}` : `${Char}${KebabToSnakeCase<Rest>}` : S; type Result = KebabToSnakeCase<"convert-this-kebab-case-to-snake-case">; // Gives: "convert_this_kebab_case_to_snake_case"
What the following line:
S extends `${infer Char}${infer Rest}`
does is that it checks if the generic S
is a string that is of the template ${Char}${Rest}
- Char
and Rest
are named
generic variables, and TypeScript will figure out what they are. If the string does conform to it, Char
will be the
first character of a string, while Rest
will be the remaining characters in the string.
Here's another example where I extract the value from an object, given the dot-separated path - just like in Lodash's get
utility:
type ExtractTypeFromPath< Path extends string, Object extends Record<string, any> > = Path extends `${infer Property}.${infer RestOfPath}` ? ExtractTypeFromPath<RestOfPath, Object[Property]> : Object[Path]; interface Person { data: { firstName: string; lastName: string; sensitive: { age: number; }; }; } // Results in number type TypeOfAge = ExtractTypeFromPath<"data.sensitive.age", Person>;
Dynamically inferring types from JavaScript
In the examples so far, we've been dealing exclusively with types - which have no impact on the actual execution of our app - after all, the compiler just strips out all the type definitions.
However, one of the most useful patterns I've found is to derive types from
actual JavaScript objects. The trick is to use as const
to
ensure that the most accurate types would be derived.
For example, if you were to do this:
const fieldNames = ["name", "age"];
typeof fieldNames
would give you string[]
However, if we did this instead:
const fieldNames = ["name", "age"] as const;
This would give you ("name" | "age")[]
- which is far more useful. We can then extract the values in the array using:
const fieldNames = ["name", "age"] as const; type FieldName = typeof fieldNames[number]; // results in: FieldName = "name" | "age"
A very simplified example of how this can be useful, is when you want to specify in JavaScript what fields a REST API endpoint should return.
const fieldsToFetch = ["first_name", "last_name", "dob", "country"] as const; async function someRestAPICall<Fields extends typeof fieldsToFetch>( fields: Fields ): Promise<{ [Property in Fields[number]]: string }> { // call endpoint and return JSON data } const response = await someRestAPICall(fieldsToFetch); // The type of response would be: { first_name: string; last_name: string; dob: string; country: string; }
So if you want to change which fields to request from the REST API, you just update fieldsToFetch
, and the
response would automatically
update to the correct type.
For example, if you removed "last_name"
, and somewhere in the application you referred to response.last_name
, Typescript will fail to compile with an error!
Update: As of Typescript 4.9, you can use the
satisfies
operator to enforce additional type safety when definingas const
objects. Read this article for more info onsatisfies
: https://www.totaltypescript.com/clarifying-the-satisfies-operator