So you’re a Javascript developer, and you want to stop writing Javascript. Typescript seems like a good option, but you’ve always used dynamically-typed languages. You’re uncertain about jumping in, or maybe don’t feel like you aren’t using it to its potential. I think I can help. Let’s ramp you into Typescript!
The first thing to know about Typescript is that you can start using it today! There’s no need to rename all of your files to .ts
and laboriously ‘typescriptify’ all at once. With its checkJs
option enabled, Typescript can analyze and find potential errors in plain Javascript files.
For example, for a Node.js project, you can add core types like this:
yarn add --dev typescript
yarn add --dev @types/node
Then add a basic tsconfig.json
file like this:
{
"compilerOptions": {
"checkJs": true,
"baseUrl": "./",
},
"include": ["./**/*.js"],
}
Then you can run Typescript checks, with no generation of compiled Javascript, like this:
yarn tsc --noEmit
You’ll probably get more complaints here, as Typescript finds components you’re using which have no type definitions. You likely find types for these other modules on DefinitelyTyped. If you can’t, you can throw a // @ts-ignore
comment right above the offending dependency, and the compiler will stop complaining.
You might be wondering: why does it need these type definitions, if it’s just Javascript? Well, just like if it were analyzing your Typescript, it needs to know the structure of your dependencies. For example, in this tiny script, it will catch that extra third parameter passed to readFileSync
:
const { readFileSync } = require('fs');
const json = readFileSync('package.json', 'utf8', 'utf8');
const data = JSON.parse(json);
console.log(data);
const text = JSON.stringify(data);
const weird = text * 5;
console.log(weird);
const reallyWeird = data * 5;
console.log(reallyWeird);
And it will also catch that text * 5
clause, because it knows the result of JSON.stringify()
is a string, and a string can’t be multiplied by five without strange results. This is our first example of type inference, where Typescript is able to determine that the text
variable is most certainly a string
, given how it was initialized.
Next, try getting some more value out of these Javascript checks by enabling a few more compiler options. You could also use JSDoc annotations to get more value out of your plain Javascript checks, but I’d recommend that you move all the way to .ts
files at this point.
any
escape valveNote that Typescript didn’t catch the data * 5
clause in the above code, which similarly produced NaN
. That’s because the return value of JSON.parse()
is any
, a special value which tells Typescript to let you do anything with that value. Call a function on it, multiply it, use it like an array - whatever!
And it turns out that’s how // @ts-ignore
works with those other dependencies without type definitions. This is your escape valve. As you start to translate a given Javascript file to Typescript, you can use any
to defer the need for fully-rigorous types.
I certainly don’t recommend using it a lot, but it’s sometimes a needed tool.
All this, and we haven’t written a line of Typescript so far!
Before we can write Typescript, we need to start at the very beginning. A type, in Javascript, is the kind of data stored in a given variable or expressed in a given constant. So the type of a variable can change, radically:
var count = 4;
count = 3;
count = 2;
count = 1;
count = 'Ah ah ah ah!';
count = {
name: 'The Count',
catchPhrase: 'Ah ah ah ah!'
type: 'puppet'
};
This single variable went from a number, to a string, to a more complex object. This is dangerous, because most code isn’t ready to deal with all three forms of this variable. Statically-typed languages like Typescript help you avoid this problem. At compile-time, the type of the variable is known either via explicit type definitions or via contextual type inference.
Given that type understanding, the compiler helps keep you honest. No, you can’t increment the value of "The Count"
. But you can increment the value of 5
.
You can think of static type-checking like setting a budget for your finances. Writing plain Javascript is like forgetting your budget or having none at all - you just spend and spend and spend. You can very easily overdraft your account, spending more than you should. Typescript is like telling a robot your intended budget - it then automatically checks your spending as you do it, loudly letting you know if things are not in good shape.
With Typescript, you put in effort up front, declaring types and fixing type issues, and you’re less likely to have bugs in your code. You also have a form of documentation in the type annotations on your functions. And perhaps most importantly, change is easier in the future. For example, when you add a new required field to the User
object, the compiler will show errors wherever you create User
objects, guiding you towards the complete change.
Now we can start writing our first Typescript. Let’s dig into some type definitions. I’ve added code for each section to the Typescript Playground - click to follow along!
Here we tell the compiler that the variable givenName
is a string
. Later, if you try to assign it to a number
the compiler will complain:
let givenName: string;
You can start to compose smaller building blocks into larger objects. This User
object has a few name fields which are strings, as well as an age
field which is a number. Its id
field is readonly
and cannot be changed once set, enforced by the Typescript compiler. Its familyName
field is optional, denoted by the question mark. This means that user.familyName
can either be a string
, undefined
, or not a key in the object at all!
type User = {
readonly id: string;
givenName: string;
familyName?: string;
age: number;
};
You can also express modality like this, using a union type - it can only be one of the chosen two values:
let familyName: string | undefined;
Union types can get a lot more interesting. The compiler won’t allow us to assign anything to the eyeColor
variable but those exact values. We can’t even assign a plain string
to eyeColor
because it might not be one of those five values!
let eyeColor: 'brown' | 'green' | 'blue' | 'hazel' | 'gray';
Now we’re starting to build up types describing more interesting structures:
type BasicLogger = {
info: (text: string) => void;
warn: (text: string) => void;
error: (text: string) => void;
getLog: () => string;
_lines: string[];
};
If you get a variable of type BasicLogger
you can call four different functions on it. Three return nothing (void
means ‘no return value’), and take one parameter, text
, a string. The fourth takes no parameters and returns a single string, the results of all of your logging so far. These are hard requirements, by the way - you can’t call these functions with missing or mismatched parameters.
You can also go directly to the logger’s _lines
field and get the lines before they are assembled into the final string
with getLog()
. The []
addition to the type signifies that it’s an array of that type.
There’s one final bit of syntax you’ll need to know to be able to describe the vast majority of Javascript data structures:
type UserMap = {
[lookupKey: string]: User | undefined;
};
We wanted to look up users really fast by their id
, so a UserMap
has dynamic keys that represent a user id
, which point to full User
objects. Sometimes. Because we can’t be guaranteed that every string
we provide to this object will return a User
. But a lot of APIs are set up this way, a lot of Javascript objects are designed to be used like this.
type UserMap = Record<string, User | undefined>;
That object structure is so common, in fact, that there’s a built-in Typescript utility type called Record
which allows you to very succinctly declare the same UserMap
type.
With Record
you saw a new syntax: <
and >
surrounding other types. This is the concept of generics. Let’s start with a simpler example.
An array has a certain shape in Javascript - length
, access to individual elements with []
, push()
/pop()
, sort()
and so on. And we saw above that you can declare an array of numbers with number[]
. But there’s another option (Typescript playground):
const numbers: Array<number> = [];
Here we’ve created a numbers
variable, an array that will only hold numbers. It’s in the shape of an array but with a number
type parameter put everywhere an array element would go. For example, instead of any value, pop()
now gives us a number | undefined
. A number
if there’s something in the array, undefined
if there’s nothing in the array.
You can use generic type parameters as you declare things as well. We’ve declared our own generic function here, which will create a new array of the chosen type:
function makeNewArray<T>(): Array<T> {
return new Array<T>();
}
We have the choice of explicitly providing the generic type parameter when we call this function, or we can rely on the Typescript compiler’s type inference of what the parameter should be. Take a look at this code:
const rightStrings = makeNewArray<string>();
const leftStrings: Array<string> = makeNewArray();
const booleans = makeNewArray<boolean>();
const users = makeNewArray<User>();
Note that we don’t need to declare the type on both sides of the =
. Choose one, and the other will be inferred. And of course, we can provide any type we want as the T
.
Let’s look at a more complex example. Here we’ve written a function that takes another async function, calls it and returns its value back. If an error is thrown, it will print that out before throwing that error again:
async function callAndLogError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof Error) {
console.log('callAndLogError: Something went wrong!', error.stack);
}
throw error;
}
}
The reason the <T>
is useful here is that we can call callAndLogError()
with any function taking no parameters and returning a promise, and that function’s return type will flow back out. No loss of type fidelity, even as we do complex things with our functions, and that means that the compiler can better verify our code!
Like our makeNewArray()
function above, generic type parameters provide ‘type hints’ in many situations where you’re using libraries or utility functions. If you’re writing React, some of these might be familiar:
const userLookup = new Map<string, User>();
const userIdList = new Set<string>();
class UpdateNameForm extends React.Component<PropsType, StateType> { ... }
const buttonRef = React.createRef<HTMLButtonComponent>();
const [name, setName] = React.useState<string | undefined>("Someone Somewhere");
In each of these cases, the generic type parameters allow the compiler to give us the checking we expect as we use these variables downstream. In the case of React.Component
it allows the typescript compiler to ensure that we’ve properly implemented all of the necessary elements of a React Component, with the right props and state shapes.
interface
While we’re on the topic of libraries, I want to make something very clear: every library is its own adventure. It’s easy to get caught up in the errors, thinking that you’ve done something wrong, or don’t know Typescript well enough. But in many cases it’s the library.
A given library may have crisp, comprehensive type definitions, or it might have lots of any
. It might even have errors in its types, and working code will be flagged by the compiler. Or, as we’ve discussed, you may find a library with no type definitions at all.
This brings us to the interface
keyword. The truth is that interfaces are used a lot like the type
keyword above, just with no =
character. Here’s the same User
object from before, declared as an interface (Typescript playground):
interface User {
readonly id: string;
givenName: string;
familyName?: string;
age: number;
}
Now, why would you want to use an interface? The answer is declaration merging. Watch what happens if we put another interface User
clause into this file - now that User
interface has another required field:
interface User {
eyeColor: 'brown' | 'green' | 'blue' | 'hazel' | 'gray';
}
const user: User = {
id: 'id3',
givenName: 'Someone',
age: 32,
eyeColor: 'green',
}
Declaration merging is how you fix types from a library you’re using, or provide your own types from scratch, or even add a few fields to window
. Because most exported library types and just about all built-in type definitions are interfaces.
It turns out that augmenting a library and providing your types from scratch look the same. broken-link-checker
is a module my blog uses to check for broken links, and it doesn’t have type definitions. These are the minimal types needed to make my code compile:
declare module 'broken-link-checker' {
export interface SiteCheckerResultType {
url: {
resolved: string;
};
}
interface OptionsType {
excludeExternalLinks: boolean;
}
interface HandlersType {
link?: (result: SiteCheckerResultType) => void;
end?: () => void;
}
export class SiteChecker {
constructor(options: OptionsType, handlers: HandlersType);
enqueue: (domain: string) => void;
}
}
Augmenting globals requires a slightly different syntax. You can extend window
, Array
, HTMLButtonElement
but I suggest that you keep it to window
, and keep it to a minimum!
declare global {
interface Window {
fetchUser: () => Promise<User>;
}
}
It’s worth it to add type definitions like this, because the alternative is to leave an any
in the code. And an any
means that you’re getting very little checking! It’s almost like writing plain Javascript, and we don’t want that!
never
We’ve talked about a lot of the basic building blocks of working with Typescript. Now let’s talk about a particularly cool kind of validation that Typescript can do for you: exhaustive checks.
Let’s go back to eye color (Typescript Playground):
type EyeColor = 'brown' | 'green' | 'blue' | 'hazel' | 'gray';
function processEyeColor(color: EyeColor): number {
if (color === 'brown') {
return 1;
} else if (color === 'green') {
return 2;
} else if (color === 'blue') {
return 3;
} else if (color === 'hazel') {
return 4;
} else if (color === 'gray') {
return 5;
} else {
const problem: never = color;
throw new Error(`Found unknown eye color: ${problem}`);
}
}
First, we’ve created a type alias here, giving the convenient name of EyeColor
to the type we declared inline previously.
Next, processEyeColor
checks against all potential values of the EyeColor
type, and then throws an error if it finds something unexpected.
The key to ensuring that this set of checks is exhaustive, checking for all potential values of EyeColor
, is this new keyword never
. This is a special value which indicates that there should be an error if the Typescript compiler believes that we could ever get to that line.
With this construction, the compiler guides us as we make changes to this code - if we add a new value to the EyeColor
type, we’ll get errors until processEyeColor
gets another check ensuring that it remains exhaustive!
Once more we return to type inference. Typescript keeps track of the potential values of variables through the flow of your functions. At the top of the function, that color
variable could have five values, and then with each successive comparison, those potential values narrow until no values are possible, or never
. To get a feel for this, hover over variables in Typescript Playground or in VSCode and follow along.
unknown
With the exhaustive checks above, you might be wondering: couldn’t a value other than EyeColor
somehow get into that variable? Sure you could present a <select>
to the user to constrain their input, but what if an old version of the app had more options? Then your types don’t describe reality!
One choice might be to relax your types to prevent any strong assumptions about their shape. But this gets dangerous when you start loading JSON from disk, pulling JSON from web APIs, pulling JSON from your MongoDB databases. Three are too many sources of questionable data!
There’s another option: runtime validation of your compile-time types. If you’ve used hapi
, you’ve used joi
to write schemas for validation of your incoming and outgoing data. And you’re maybe thinking that you don’t want to define Typescript types and joi
types. And I agree - that’s tedious.
To solve that, I suggest a library called zod
. It allows you to define runtime types using its API, which are then available to Typescript for compile-time validation. Here’s the same code to process eye color from above, with both runtime and compile-time guarantees that our code works (Typescript Playground):
import { z } from 'zod';
const eyeColorSchema = z.enum(['brown', 'green', 'blue', 'hazel', 'gray']);
type EyeColor = z.infer<typeof eyeColorSchema>;
export function processEyeColor(incoming: unknown): number {
const parsed = eyeColorSchema.safeParse(incoming);
if (!parsed.success) {
throw new Error(`processEyeColor: Failed to parse eye color: ${parsed.error.flatten()}`);
}
const color = parsed.data;
if (color === eyeColorSchema.enum.brown) {
return 1;
} else if (color === eyeColorSchema.enum.green) {
return 2;
} else if (color === eyeColorSchema.enum.blue) {
return 3;
} else if (color === eyeColorSchema.enum.hazel) {
return 4;
} else if (color === eyeColorSchema.enum.gray) {
return 5;
} else {
const problem: never = color;
throw new Error(`Found unknown eye color: ${problem}`);
}
}
The key is the z.infer
call which allows Typescript to use type inference to give you access to the type you declared in code. It’s the same set of values as before, but zod
gives us access to the values at runtime as well (like eyeColorSchema.enum.brown
).
We use unknown
as the parameter of processEyeColor
, because we have no idea where this data came from, and we want Typescript to force us to validate it before we use it. unknown
is the opposite of any
- you can’t use it until you’ve explicitly verified its shape. Here we could dispense with the parsing and do direct ===
checks against the unknown
value, but then we’d lose our exhaustiveness guarantee.
That’s why we use our schema to parse the value into our expected type. safeParse
takes unknown
and returns a discriminated union type:
{ success: true; data: T; } | { success: false; error: ZodError; }
These kinds of unions take advantage of Typescript type narrowing. If success
is true, we know that we have a data
field of the type we expect. If success
is false, we have an error
field.
Now you’re ready to start using Typescript in your project! You should be able to turn these compiler type-checking options on quite easily, without a whole lot of pain fixing the issues found:
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"alwaysStrict": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictBindCallApply": true,
I would advise that you turn this next set on as well, but I acknowledge that they might require more changes to your code. It’s worth it, though, because the things found are all potential bugs, either now or in the future. In the case of noImplicitAny
, it means a lurking any
, which means you’re pretty much just writing Javascript!
"noImplicitAny": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
The rest of the type checking rules are, in my view, debatable:
// Almost an aesthetic decision - what does an optional field mean?
"exactOptionalPropertyTypes": true,
// Are you sure that you're always catching real Error objects? If not, turn this on
"useUnknownInCatchVariables": true,
// The definition changes over time!
"strict": true,
Alright, with that you’ve got the type-checking under control.
Now, how will you go from Typescript to the code you ship? You can tell Typescript to output your code to a dist/
location, which works fine for a node module or Node.js app. For this method, you could also try a cutting-edge compiler written in rust - it wouldn’t type-check, but you can easily do that with tsc --noEmit
like we did above.
If you’re building via Webpack, you need to decide: do you want your Webpack build to fail with type-checking failures? Typescript can still generate workable code even if type-checking fails, so this may not be what you want since it slows down the build. You can turn off type checking in ts-loader
, then again use tsc --noEmit
for type-checking.
Finally, do you want to get even more strict and start using Typescript-aware eslint
? Know that it can be very slow - you’ll want to use eslint’s caching feature. I think it’s worthwhile; it has a whole lot of useful rules. To fully take advantage of Typescript’s type-checking, I recommend that you enable at least three rules: @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types, and @typescript-eslint/no-non-null-assertion.
Start the move to Typescript sooner rather than later! You can start small, but you and your team will move faster once you have comprehensive types in place, allowing Typescript to give you good compile-time guardrails.
At the very least, write new features in Typescript, avoiding any
or other type compromises. Otherwise, every new feature you write will take you further from the confidence that Typescript can provide!
I’d love to hear about your challenges in moving to Typescript - please reach out. Maybe I can help, and maybe I can help others in my next article like this!
.tsx
)EyeColor
type above - you get runtime access to the values without zod
!readonly
fields. It prevents calls to functions that mutate in-place too. Use it.NaN
to Object
Document
to MouseEvent
to indexedDB
LeftType & RightType
. You do this with interfaces as well, with extends
.keyof
and typeof
to get meta with your typesT
will always be able to be anything, which will greatly limit what you can do with them.I’ve been told that I’m a very productive developer. And it’s not magic; it’s a set of skills you can build! Welcome to the sixth in my developer productivity tips series: Take breaks. I’ve spoken... Read more »
Yes, I’ve written a lot of Javascript. But it’s honestly an accident that we’re using it for such large, complex applications. It’s not a good language. We need to move on. Let’s talk about how you... Read more »