Strict Mode
TypeScript has a strict
flag which enables additional type checks, resulting in stronger guarantees of program correctness. It's highly recommended enabling this flag in your tsconfig.json
:
{
"compilerOptions": {
"strict": true
}
}
One of the additional behaviors is the stricter null
check. Without strict mode you can use variables that are possibly null
without checking if they are null
.
// "strict": false
function foo(bar: string | null) {
// Runtime error if `bar` is null
console.log(bar.toUpperCase());
}
// "strict": true
function foo(bar: string | null) {
// Not allowed, TS shows an error
console.log(bar.toUpperCase());
// How to fix: check if `bar` is a string
if (typeof bar === "string") {
console.log(bar.toUpperCase());
}
// or, if you want to log `null`
console.log(bar?.toUpperCase());
}
Accessing array elements
TypeScript allows you to access an array element without checking if the element actually exists:
function foo(items: string[]) {
// TypeScript is happy with that
console.log(items[0].toUpperCase());
}
In the example above, the function will throw an error if the items
array is empty, because items[0]
returns undefined
and doesn't have a toUpperCase
function. TypeScript doesn't warn you about that, regardless of whether strict
mode is enabled or not.
The flag noUncheckedIndexedAccess
helps you to catch these kinds of bugs. Enabling it will add undefined
to a type:
function foo(items: string[]) {
const [first] = items;
if (first !== undefined) {
console.log(item.toUpperCase());
}
}
Type casting objects
I admit it: type casting is sometimes necessary. But some developers use it to type objects like this:
interface User {
id: number;
firstName: string;
lastName: string;
}
const user = {
id: 1,
firstName: "John",
} as User;
This approach is very problematic. The object isn't a valid User
object - the lastName
property is missing. Avoid this syntax and use the type safe way instead:
const user: User = {
id: 1,
firstName: "John",
lastName: "Doe",
};
Type guards
In the following example the variable items
is an array of strings, but some elements are null
. You now want to remove the null
values:
const items = ["one", null, "two", "three"];
const validItems = items.filter((item) => {
return typeof item === "string";
}) as string[];
The filter()
function removes all non-string values, but TypeScript doesn't understand this code and validItems
is still of type Array<string | null>
- that's why we type cast the value. But again, we should avoid type casting using the x as y
syntax.
Fortunately, there is a cleaner approach using type guards. A type guard is a function that returns a boolean value and narrows the type of a variable. We can use a type guard to remove null
values:
const validItems = items.filter((item): item is string => {
return typeof item === "string";
});
The function provided to filter()
now returns whether item
is of type string
. TypeScript now understands that validItems
is an array of strings without null
values.
Unhandled cases in switch statement
The following function foo
uses a switch statement and returns a different value depending on the value of color
:
type Color = "primary" | "secondary";
function foo(color: Color) {
switch (color) {
case "primary":
return {
/* ... */
};
case "secondary":
return {
/* ... */
};
}
}
Do you spot the problem in the example above? If one day, the Color
type is extended with a new value, let's say neutral
, the function will not work properly anymore. Depending on how the function is used you may get a compiler error, but that's not guaranteed.
Whenever you use a switch statement, make sure to handle all cases. There's a trick using the never
type to assert that all cases are handled:
function foo(color: Color) {
switch (color) {
case "primary":
return {
/* ... */
};
case "secondary":
return {
/* ... */
};
default:
assertUnreachable(color);
}
}
function assertUnreachable(_value: never): never {
throw new Error("Statement should be unreachable");
}
If all cases are handled, color
will be of type never
in the default case. If a new color is added, the compiler will show an error:
Argument of type 'string' is not assignable to parameter of type 'never'.
Unions
Let's say you have two interfaces, EmailLink
and WebLink
.
interface WebLink {
type: LinkType.WEB;
url: string;
}
interface EmailLink {
type: LinkType.EMAIL;
email: string;
}
Now you want to have a generic type Link
that supports both types. You could create an interface and extend the two existing interfaces with it:
interface Link {
type: LinkType;
}
interface EmailLink extends Link {
/* ... */
}
interface WebLink extends Link {
/* ... */
}
This approach works but is problematic as it doesn't support type inference. Given a function openLink()
that receives a link, you have to type cast the object based on the type
property:
function openLink(link: Link) {
if (link.type === LinkType.EMAIL) {
const emailLink = link as EmailLink;
// ...
} else if (link.type === LinkType.WEB) {
const webLink = link as WebLink;
// ...
}
}
A better way is using a union type which automatically infers the type when checking the type
property without the need for type casting:
type Link = WebLink | EmailLink;
Now the openLink()
function doesn't have to type cast the link:
function openLink(link: Link) {
if (link.type === LinkType.EMAIL) {
// TypeScript knows that `link` is an EmailLink
window.open(`mailto:${link.email}`);
} else if (link.type === LinkType.WEB) {
// TypeScript knows that `link` is a WebLink
window.open(link.url);
}
}
Translations
TypeScript is not just for your application code but for your translations too. If and how to use TypeScript for translations depends on how you handle translations and what library you use.
If you use an extraction tool, that automatically creates the translation files from your code base, you may not need (or be able) to use TypeScript.
Some translation libraries like i18next provide a way to work with translation keys in a type safe way. Other libraries like Transloco for Angular don't support this out of the box.
Translation files don't have to be JSONs, you can use TypeScript files too. This way you can ensure that all translations have the same keys and no translations are missing in a language. In addition, you can define a TranslationKey
type that gives you all valid keys (which is possible with JSONs too).
// en-US.ts
const enUS = {
key1: "Hello",
key2: "Goodbye",
};
export type Translations = typeof enUS;
export type TranslationKey = keyof Translations;
// de-DE.ts
import type { Translations } from "./en-US";
// TypeScript warns you about the missing key `key2`
const deDE: Translations = {
key1: "Hallo",
};
Now let's say you have a Status
enum and want to display a translated label in the UI. Thanks to the TranslationKey
type you can ensure that a translation exists for each enum value:
const label: TranslationKey = `some-component.status.${myEnumValue}`;
You can also make it more explicit if you want:
type StatusTranslation =
`some-component.status.${Status}` extends TranslationKey
? `some-component.status.${Status}`
: never;
const label: StatusTranslation = `some-component.status.${Status.Draft}`;
Satisfies operator
TypeScript 4.9 introduced the new satisfies
operator. Using the satisfies
operator you can ensure an object is of a given type while preserving the actual type of the object.
Given the following User
interface and user
object:
interface User {
firstName: string;
lastName: string;
metadata: Record<string, string>;
}
const user: User = {
firstName: "",
lastName: "",
metadata: {
pet: "Cat",
},
};
The user
object is now of type User
and user.metadata
resolves to a generic Record<string, string>
object. Accessing user.metadata.pet
is not type safe anymore.
// Not type safe anymore
console.log(user.metadata.pet);
// `dog` doesn't exist but no error :(
console.log(user.metadata.dog);
So far we had to use functions to improve type safety for such use cases (which is still useful if you use an older TypeScript version):
function createUser<U extends User>(user: U) {
return user;
}
const user = createUser({
firstName: "John",
lastName: "Doe",
metadata: { pet: "Cat" },
});
// Type safe ✅
console.log(user.metadata.pet);
With satisfies
we don't need the runtime function:
const user = {
firstName: "John",
lastName: "Doe",
metadata: { pet: "Cat" },
} satisfies User;
// Type safe ✅
console.log(user.metadata.pet);
Template literals and primitive types
The example below is a function that expects an ISO date string and formats the date.
function formatDate(isoDateString: string) {
const formattedDate = "...";
return formattedDate;
}
The function accepts an ISO date string but uses the generic string
primitive type. To make your code more expressive you should create an alias type for it:
type DateISOString = string;
function formatDate(date: DateISOString) {
const formattedDate = "...";
return formattedDate;
}
However, there is a limitation: when calling the function your IDE may still just show string
instead of DateISOString
. But it's still helpful when reading the function code.
If a value is not just any string but has a specific format, you can use template literal types to make the format more explicit. For the DateISOString
this would be something like this:
type DateISOString =
`${number}-${number}-${number}T${number}:${number}:${number}.${number}Z`;
This type doesn't enforce a valid ISO string, you can still pass an invalid date like 0-0-0T0:0:0.000Z
. But it helps developers to know the expected format.
Template literal types are useful to define the expected format instead of using a generic string. That doesn't mean you should always use them. In case of an ISO string it's probably better to use just string
. The expected format is known anyway (not something specific to your application) and it's unlikely that you manually create a string, but you will use something like date.toISOString()
instead.
That's it. I hope you learned something new and the tips help you to write better TypeScript code.