5 Tips To Make The Most Out Of TypeScript

Chris Coppola
15 min readAug 7, 2021
Photo by Max Duzij on Unsplash

TypeScript has taken the JavaScript world by storm. For me personally, going back to pure JavaScript is an impossible task. It gives you the obvious, compile-time type checking, but it also does a lot more. Try converting one JavaScript file of decent size over to TypeScript (preferably on strict mode) and count how many bugs just dealing with assumed types and null checks you fix. Some complaints are legitimate when it comes to TypeScript, such as long compile times, and increased coding time. But many complaints are just not very constructive and just seem, naive and ill-advised.

I‘ve seen dozen’s of people complaining about how TypeScript is too verbose, less readable, etc. It’s all not true. Those statements say more about your code than they do about TypeScript. TypeScript allows you to write safer, more readable, and refactor-able code. You write code that you and your team can be proud of, and can be at more confident that your code will work when you ship to production.

And for any other bloggers that say, “Oh well, if you have unit tests you don’t need TypeScript.”, that is such a silly and uninformed answer. TypeScript and unit testing both complement each other, and are even better when used together. In fact, some unit tests make less sense when you already have compile time checking. You end up saving time not having to check 100+ edge cases for possible function parameters, because TypeScript already taught you to stop using variable parameter types and to avoid old JavaScript shinanigans. TypeScript enhances your testing strategy and gives your team confidence, as long as you don’t cut corners with the type system.

If you have a large team or project and you have the ability to, just make the transition to TypeScript. Being able to code quicker is not a good enough reason to trade readability, refactor-ability, compile-time type safety, and improved code conventions. If you are working on a small personal project, I’ll let you slide, you can use it once you’re ready :)

TypeScript is an invaluable tool, especially when used correctly. No one is forcing you to make complex multi line generics, or to compile huge projects that take minutes to compile. But if your problem with TypeScript is that your code is too long or it takes too long to write, you are greatly underestimating how much time it saves when it comes time to bash bugs, refactor code, or when someone else decides to finally look at your code. It forces you to code in a way which is self-documenting. There is no downside to that, especially if you were planning on writing docs anyway. It only enhances your code and helps you write better unit tests.

So now with my rant complete, we can dive into what I really want to talk about. Let’s get into some of my favorite things to do in TypeScript that makes using and managing TypeScript code much better. Using the type system well helps express your ideas better, and helps others understand what you are trying to do.

The key to using TypeScript well is to allow it to add additional information all over your code. Well documented, and well designed code can communicate intent to the rest of your team, which is an invaluable and often forgotten form of communication. TypeScript literally makes you add types, and alerts you of some bad code conventions along the way (like not checking undefined or null properly). There are even hidden code improvements just due to how you see your application and choose to design your code.

So much wow! Let’s get to the list. Here are 5 techniques and conventions I like to use in TypeScript.

#1 Use Type Imports & Separate From Real Imports

There are a couple of different recommendations when it comes to importing code. Some common ones are, avoid import * as syntax, and avoid default imports and exports all together. I personally follow both of these rules, as they help keep code optimized via tree-shaking (obviously this is highly dependent on your build tools, most handle it well now) and helps autocomplete with intellisense. Using default sometimes works with autocomplete, and sometimes it doesn’t. I prefer it always works, and it’s always the name I want.

Once we add TypeScript, everything works pretty much as we expect. To me, their is a hidden cost to just using intellisense to do all of your imports. You end up with something like this…

import {
BoundingBox,
calculateBoundingBox,
convertToPointsList,
Geometry,
Point,
transformPolygon,
Vector
} from '@libs/geometry'

Now some people may say, “this is great, no problem here”. But there is a problem for me. How do we know if BoundingBox, Geometry , Point, and Vector are classes or types? Maybe our IDE theme colors them differently, and that’s great, but it simply makes code that much less readable. You have to assume that some juniors on your team, will just not notice the colors and will be taking extra time to identify which imports are types and which are real concrete objects. And to be honest, we can’t really be confident someone follows the convention that types always use PascalCase, functions use camelCase, etc. Different teams may follow slightly different conventions, so it could become much worse than what’s above. Let’s just start a new system to solve the problem.

The TypeScript team knew this was somewhat of an issue, so TypeScript v4+ allows us to use the import type syntax. This syntax allows us to declare an import as only having TypeScript types. This is good because it can communicate just a little bit better to programmers how our code works. We can use it by doing the following…

import type {
BoundingBox,
Geometry,
Point,
Vector
} from '@libs/geometry'
import {
calculateBoundingBox,
convertToPointsList,
transformPolygon,
} from '@libs/geometry'

Great, Seems like almost nothing! But now on top of our naming conventions, the actual import statement itself gives us information on what is being imported. It actually helps the compiler remove the type information much quicker, as the entire import type statement can be removed without any additional checks (probably a minuscule improvement, the real bonus is communication).

This works at the top of the file quite well, and I actually believe that with the new import type-only syntax, we can actually use import type * as syntax quite well to work as a namespace and provide even more information. This doesn’t really have a performance downside of potentially complicating tree-shaking as it may with normal code.

import type * as Geometries from '@libs/geometry'
import {
calculateBoundingBox,
convertToPointsList,
transformPolygon,
} from '@libs/geometry'
// ...function doSomething(
list: Geometries.Point[]
): Geometries.Geometry { /* ... */ }

As you see above, doing it in this way adds EVEN more hidden information as we use types. It now tells us where the types live. This to me is much better than having either global types, or just importing the type itself. Once you start using it, you will also see that this makes using many types from the same package much easier. Basically, this is a more modular way of using namespaces! Try this out and see if you like it.

#2 Use Type Guards as much as possible

Type guards help bridge the gap between TypeScript’s compile-time type checking, and JavaScript during run time. Almost all TypeScript specific syntax and code is stripped during the transpilation phase, so it provides virtually NO PROTECTION at runtime. Without type guards, you would still undoubtedly write better code, handle null checks better, etc; but your code will have some gaping holes when it comes to having real confidence your code will work in production.

For those who may not know, a type guard is a special function that can confirm that something is a specific type. If you commonly use class syntax, and or object-oriented programming, this is similar to using instanceof. We can do much cooler things with TypeScript, and we need something more versatile than instanceof, especially with so many people coding in a more functional way, favoring immutable POJO’s instead of classes.

Here is an example of a simple type guard, and an example of it’s use.

function isString(value: unknown): value is string {
return typeof value === 'string'
}
// ...const stringOrNumber: string | number = generateRandomStringOrNumber()if(isString(stringOrNumber)){
console.log(`stringOrNumber is a string`)
} else {
console.log(`stringOrNumber is a number`)
}

This is great! Even in the above, pretty unrealistic example, we can get some ideas about how powerful this is. If you don’t see it, imagine we have a larger object to test.

type Geometry = {
_id: number
_kind: 'math/geometry'
attributes: {
position: Point
rotation: number
label: string
} & (
{
type: 'square'
sideLength: number
} | {
type 'circle'
radius: number
} | {
type: 'rectangle'
height: number
width: number
} | {
type: 'triangle'
height: number
base: number
isFlippedX: boolean
}
)
meta: {
createdOn: Date
lastModified: Date
modifiedBy: number
}
}

The above type involves many more properties, as well as union types, meaning the structure is flexible. We can easily handle this with some refactoring and some type guards…

// First, lets give each shape a proper type
type SquareAttributes = {
type: 'square'
sideLength: number
}
type CircleAttributes = {
type: 'square'
sideLength: number
}
type RectangleAttributes = {
type: 'rectangle'
height: number
width: number
}
type TriangleAttributes = {
type: 'triangle'
height: number
base: number
isFlippedX: boolean
}
// We will also give the common attributes a type
type CommonShapeAttributes = {
position: Point
rotation: number
label: string
}
// Let's make the union type more explicit, and reusable
type ShapeAttributesUnion =
SquareAttributes
| CircleAttributes
| RectangleAttributes
| TriangleAttributes
// Let's now replace the complex union type with something simpler and easier to read
type Geometry = {
_id: number
_kind: 'math/geometry'
attributes:
CommonShapeAttributes
& ShapeAttributesUnion
meta: {
createdOn: Date
lastModified: Date
modifiedBy: number
}
}

The code has now been made easier to work with, as well as easier to read. We can now start making some type guards. Let’s start with isSquareAttributes.

function isSquareAttributes(
value: unknown
): value is SquareAttributes{
if(typeof value !== '[Object object]')
return false
if(!('type' in value) || value.type !== 'square')
return false
if(!('sideLength' in value) || typeof value.type !== 'number')
return false
return true
}

So this will work just fine. We just as easily could use a library just as ajv with JSON Schema to validate even more complex types. We can make this slightly better by understanding how this will be used, we can see a small issue is that the object we will most likely be checking will be one of the 4 shapes in ShapeAttributesUnion, intersected with CommonShapeAttributes. We can fix the type, and type guard to reflect this. In addition to that, we can make a separate type guard isCommonShapeAttributes and use it within our isSquareAttributes function. We can also make a type guard for the entire ShapeAttibutesUnion.

Below is another example. I’ve added some helper functions and type guards to simulate what you may see in a real project.

type WithCommonShapeAttributes<T> =
T & CommonShapeAttributes
type SquareAttributes = WithCommonShapeAttributes<{...}>type ShapeAttributesUnion = ...function isCommonShapeAttributes(
value: unknown
): value is CommonShapeAttributes {
if(!isObject(value)) return
if(!hasKey(value, 'position') || !isPoint(value.position))
return false
if(!hasKey(value, 'rotation') || !isNumber(value.rotation))
return false
if(!hasKey(value, 'label') || !isString(value.rotation))
return false

return true
}
function isSquareAttributes(
value: unknown
): value is SquareAttributes {
if(!isCommonShapeAttributes(value))
return false
if(!('type' in value) || value.type !== 'square')
return false
if(!('sideLength' in value) || typeof value.type !== 'number')
return false
return true
}
// ...function isShapeAttributesUnion(
value: unknown
): value is ShapeAttributesUnion {
return [
isSquareAttributes,
isCircleAttributes,
isRectangleAttributes,
isTriangleAttributes
].some(value)
}

Here we see how composing type guards together can help us check even complex union types. We use isCommonShapeAttributes within our isSquareAttributes, and we use isObject and hasKey within isCommonAttributes. We use all 4 of our shape type guards in isShapeAttributesUnion, and use some fancy syntax to ensure at least 1 of the type guards return true.

As we build up more and more type guards, we can compose them even more to do even more complex checks, without adding any complexity and keeping code readable. Most of all, the code will be much safer in actual use. We can guarantee the shape of an object before we decide to use it, and with some modification we can add logs to debug issues, filter out types that will cause errors, and more.

Let’s not forget our original goal, we wanted to type check the Geometry object. Once we have built our repertoire of type guards, this will be easy for such a small object! Below is what that would look like.

function isGeometry(
value: unknown
): value is Geometry {
if(!isIdentifiable(value)) return
if(!isOfKind(value, 'math/geometry')) return
if(!hasKey(value, 'attributes')) return
if(!hasKey(value, 'meta') return
if(!isShapeAttributesUnion(value.attributes)) return
if(!isStandardResourceMeta(value.meta) return
}

Wow, so much better. This is much more satisfying to look at, and will be a whole lot easier for a junior to understand. You can use your imagination to figure out how the rest of these type guards may work. You may choose to only check a handful of properties if you don’t value the full validation.

If it gets any more complicated than above, I would recommend using a schema validation library, such as Ajv.

#3 Use Unions To Reduce Types Easily

Most junior programmers I see don’t understand the true power of unions. We can do the above work and make type guards for our unions, but we get the same effect in an even easier way. Check out the below example (using types from above)

type RectangularUnion = 
SquareGeometry
| RectangleGeometry
const shape: RectangularUnion = getRandomShape()if(shape.type === 'rectangle'){
console.log('shape is a rectangle!')
} else if(shape.type === 'square'){
console.log('shape is a square!')
} else {
console.log('what is shape?')
}

As you can see above, we are just checking the type property to see if it equals a certain value. RectangularUnion can have the same type property of any type in the union (In this case, square or rectangle). Just by checking this value, we can convince the TypeScript compiler of what the type is. TypeScript will also, as is hinted in the last else, not know what the object is if it does not match anything in the union. That’s good, because the last else can never happen, as long as you’ve been honest with your types and have not used any too much.

This will also work, if you look for a unique key within the object. Check out the example below.

const shape: RectangularUnion = getRandomShape()if(hasKey(shape, 'sideLength')){
console.log('shape is a square!')
} else {
console.log('we can assume shape is a rectangle!')
}

Once again, we see how easy it is to discern between types within the union. This only works because sideLength is a unique property to squares. We could not do this if the property is common among more than 1 type in the union. This can usually be avoided by adding a property such as type to uniquely identify a specific type.

#4 Stop Using Any Entirely, Be Honest When You Do & Use Unknown If You Have To

This is easier said than done for some. It is not always easy to see how using any can be avoided in situations. It would require a good understanding of type guards, handling non-typescript libraries, etc. Sometimes third-party libraries use any that you cannot avoid.

Step 1 is at least, don’t use any yourself. We have seen in the above type guards that we use unknown instead of any. unknown forces us to do proper type checking eventually. When combined with type guards, you can handle just about any situations within your app. any, on the other hand actively encourages not checking types to your fellow teammates. As I am sure you already know, using any is essentially lying to the TypeScript compiler and this opens you up to bugs in the future. It gives you immediate relief today, but much pain tomorrow. The intent communicated by using unknown is that we want to find out what that thing is. The intent communicated by using any is that we don’t care what it is.

After you and your team are on board, the harder problem is how to handle libraries. The long, but correct answer is, add types for the library yourself! And optionally submit a pull request to the library if you want as well. You may not have enough time to do that, or simply don’t want to. That’s fine, you can decide what’s best for your situation. Here are your options…

  • Fork the library, add types, submit pull request, publish a version of your own to npm if you cannot wait for pull request to be merged.
  • Add a type declaration file for the module. I typically have it live in @types/vendor/module-name.d.ts. Only add types for what you use.
  • Add a type declaration file for the module. Just declare the module with no body (which means the import will be of type any).
  • Reduce the use of any to a limited number of folders or files within your app. This may mean adding wrapper functions after using library types of type any. Follow the rule that no function can return any. Use type guards to convert any to proper types before returning.

My guess, is you can do at least 1 of the above 4 in any situation. Of course, not being in control of the library makes this a rather annoying problem. And also, if doing things for work you almost always have some deadline you need to meet. So at the minimum, just think about using project structure, functions, and type guards in a smart way to at least make any manageable. Don’t let any take over your app, you will eventually regret it.

#5 Practice Using Mapped and Conditional Types

Mapped types and conditional types are examples of pure “TypeScript Magic”. These are extremely powerful and can be used create types with complex relationships.

Conditional types allow us to do conditional statements within a type, forking a type based on if it extends another type. This works using the following syntax, T extends string ? A : B. This is essentially a ternary operator. T is the type we want to check, in this case we are testing if it is a string or something that extends string. If true, it would return type A, otherwise it would return type B.

This seems powerful enough, but checkout the following…

type ExtractType<T, U> =
T extends U
? U
: never
type ExtractFunctionParameters =
T extends (...args: infer A) => any
? A
: never

The infer keyword is straight up, the most powerful tool in TypeScript. It allows you to ask the TypeScript compiler to figure out what that type could be. The above is a simple use case for it, but it allows us to dive deeper into collection types, functions, unions, and more.

The first example will literally, just return the passed in type U, or never. Try to think of a good use case for this before moving on.

The second example can extract the parameters from a function. This is extremely powerful if you want to make a related function that is based on another, possible with additional parameters (Higher order functions, for example).

Note: TypeScript actually has helper functions for the above 2 use-cases. Extract<T> and Parameters. See the actual implementations here and here.

Let’s use both together, to do some crazy stuff…

/**
* An example type to test Mapped and Conditional types.
*/
type Resource = {
id: number;
attributes: {
name: string;
flags: {
isAvailable: boolean;
};
};
};
/**
* An example of a conditional type. This converts types to readable strings.
*/
type AsDisplayableType<T> = T extends string
? "String"
: T extends number
? "Number"
: T extends boolean
? "Boolean"
: T extends symbol
? "Symbol"
: T extends Date
? "Date"
: T extends any[]
? "Array"
: T extends (...args: any[]) => any
? "Function"
: T extends {}
? "Object"
: "Unknown";
/**
* A helper type to so you can use mutable or readonly types as a single type.
*/
type MutableOrReadonly<T> = Readonly<T> | T;
/**
* This is a type that describes a specific key within an object.
*/
type AsTypeDescriptor<K, V, P extends MutableOrReadonly<string[]>> = {
key: K;
path: P;
type: AsDisplayableType<V>;
};
/**
* This is a 'Mapped' type that uses 'Conditional' types as well to iterate deeply
* through an object, creating TypeDescriptors. Note how it passes down the path as it
* recursively iterates.
*/
type MappedAsTypeDescriptors<
T extends {},
P extends MutableOrReadonly<string[]> = []
> = {
[K in Extract<keyof T, string>]: T[K] extends
| string
| number
| boolean
| symbol
| Date
| any[]
| ((...args: any[]) => any)
? AsTypeDescriptor<K, T[K], P>
: MappedAsTypeDescriptors<T[K], Readonly<[...P, K]>>;
};
/**
* Using the 'Mapped' type to create a type that describes the types of each key within an object.
*/
type ResourceTypeDescriptor = MappedAsTypeDescriptors<Resource>;
/**
* Below are real objects for the 'Resource' and 'ResourceTypeDescriptor' types
*/
const resourceDescriptor: ResourceTypeDescriptor = {
id: {
key: "id",
path: [],
type: "Number"
},
attributes: {
name: {
key: "name",
path: ["attributes"],
type: "String"
},
flags: {
isAvailable: {
key: "isAvailable",
path: ["attributes", "flags"],
type: "Boolean"
}
}
}
};
const resource: Resource = {
id: 123,
attributes: {
name: "Chris",
flags: {
isAvailable: true
}
}
};

I recommend actually playing with the above to code on Code Sandbox to get a handle on what the heck is going on. I’ve added comments to try to explain, but intellisense and manually inspection is a must for this. The above use-case is semi-realistic. If you want a real understanding of these types, start investigating some of the more complex TypeScript libraries such as Ramda.js, which make extensive use of these techniques. But the only real way to learn these techniques, is to actually practice using them.

Final Thoughts

Some of the above techniques help make TypeScript as useful as possible for you and your team. There is a focus on letting TypeScript be a communication tool for your team, as well as a focus on defining types well to make the most out of the type-safety and to prevent errors. TypeScript can be complex, but that’s a good thing. It means you can describe a wide variety of types, as well as communicate and organize your ideas. It also means that, you will have to practice if you want to become a TypeScript master!

So, what you think of these 5 tips? Was there something I missed? Please, let me know in the comments down below…

--

--

Chris Coppola

Founder of Coppola Creative | Tech-savvy engineer, gaming, music, and quantum physics aficionado | TypeScript Wizard | NYC, US.