5 Tricks To Make The Most Out Of Typescript
Keep bugs out of production by utilising the language features offered by TypeScript. Be instantly warned of invalid values, undefined properties, or incompatible objects.
Note: This guide assumes that you are using a tsconfig.json
with strict type-checking rules, such as this.
Optional chaining with ?.
The optional property accessor operator ?.
will allow you to access a property on an object that is possibly undefined, without throwing a ReferenceError
at run time.
This can also be useful when dealing with objects whose true types are not provided. For example, a variable typed with any
will not throw a TypeError when accessing a non-existent property, but they will throw a ReferenceError
at run time.
const untypedVar: any = { validProp: "value" };
untypedVar.validProp // Returns "value"
untypedVar.invalidProp // Returns undefined
untypedVar.invalidProp?.child // Returns undefined
untypedVar.invalidProp.child // ReferenceError: invalidProp is not defined
Null coalescing with ??
The nullish coalescing operator ??
allows you to ‘fall back’ to another value when the predicate is null
or undefined
.
For example:
1 ?? 2 // Returns 1
null ?? 2 // Returns 2
undefined ?? 2 // Returns 2
While the ??
operator only falls back for null
and undefined
, there is a similar operator called the double pipe operator ||
which instead falls back for falsy
values.
For example:
0 ?? 2 // Returns 0, since 0 is NOT null or undefined
0 || 2 // Returns 2, since 0 is falsy
These operators can be combined with the optional property accessor above to provide full type-safety for methods that may throw errors. For example, a HTTP fetch method:
const fetchData = async (): Promise<string[]> => {
let definiteResponse: GaxiosResponse<string[]> | undefined;
await request<string[]>({
url: `www.example.com/api`,
method: "GET",
})
.then((response: GaxiosResponse<string[]>) => (definiteResponse = response))
.catch((response: GaxiosError<string[]>) => console.error(response));
return definiteResponse?.data ?? [];
};
This method ensures that if the request fails with an error, then we can simply log the error and return an empty array.
Optional types
A time may come where we want a property to be used some times, but not all of the time. This can be achieved by marking the property as optional
by using the optional property operator, ?:
Example – Optional method parameters:
const myMethod = (requiredProp: string, optionalProp?: string): void => {};
myMethod("arg1", "arg2"); // Valid
myMethod("arg1"); // Valid
myMethod(); // TypeError
Example – Optional interface attributes:
interface MyInterface {
requiredProp: string;
optionalProp?: string;
}
let myVar: MyInterface;
myVar = { requiredProp: "arg1", optionalProp: "arg2" }; // Valid
myVar = { requiredProp: "arg1" }; // Valid
myVar = {}; // TypeError
We can also force all of an object’s attributes to be optional by wrapping it’s type with Partial<>
. In this example, the requiredProp
becomes optional because it’s parent type is Partial<MyInterface>
let myVar: Partial<MyInterface>;
myVar = { requiredProp: "arg1", optionalProp: "arg2" }; // Valid
myVar = { requiredProp: "arg1" }; // Valid
myVar = {}; // Valid
Union types
Rarely you may come across a variable whose type could be several different things. In those cases, you may be tempted to resort to using the any
type, which will accept literally any type. But using any
is bad practise, and it would be preferable to still add some strictness to the property type. For these cases, you can use union types.
Example – Interface whose properties can be either string
or number
:
interface MyInterface {
requiredProp: string | number;
optionalProp?: string | number;
}
let myVar: MyInterface = { requiredProp: "" };
myVar.requiredProp = "foo"; // Valid
myVar.requiredProp = 10; // Valid
myVar.requiredProp = true; // TypeError
Fun fact: When a property is optional, it becomes union-typed with undefined
interface MyInterface {
requiredProp: string | number | undefined;
optionalProp2?: string | number;
}
let myVar: MyInterface = { requiredProp: "" };
myVar.requiredProp = undefined; // Valid
myVar.optionalProp = undefined; // Valid
Deep property types with ts-object-path
The ts-object-path package allows you to take advantage of some of the crazier features of TypeScript.
Imagine you have a deep object such as this RootObject
:
interface MiddleObject {
child: number;
}
interface RootObject {
middle: MiddleObject;
}
Now imagine that we want to be able to update this object from multiple places, without directly mutating the base object. Well we can do that using ObjProxyArg
from ts-object-path
.
const updateObject = <T>(proxy: ObjProxyArg<RootObject, T>, context: Partial<T>): void => {
set(root, proxy, context);
};
Lets look at this method a little closer.
ObjProxyArg
– allows us to use an arrow function to provide a reference to deeply nested propertiesObjProxyArg<RootObject, T>
– indicates that we will be accessing a property on aRootObject
, but we are not sure what property it will be (hence the genericT
type parameter), or how deeply nested it is.
(o) => o // Type is RootObject
(o) => o.middle // Type is MiddleObject
(o) => o.middle.child // Type is number
Partial<T>
– indicates thatcontext
will determine its type based on the property requested usingObjProxyArg<RootObject, T>
set
– a method fromts-object-path
that allows a base object to be updated depending on theproxy
andcontext
Which means that we can now update our base object using:
updateObject((o) => o.middle.child, 1); // Valid
updateObject((o) => o.middle.child, "1"); // TypeError
updateObject((o) => o.middle.child, null); // TypeError
Full example:
import { ObjProxyArg, set } from "ts-object-path";
interface MiddleObject {
child: number;
}
interface RootObject {
middle: MiddleObject;
}
const initObject = (): RootObject => {
return {
middle: {
child: 0,
},
};
};
const root: RootObject = initObject();
const updateObject = <T>(proxy: ObjProxyArg<RootObject, T>, context: Partial<T>): void => {
set(root, proxy, context);
};
it("deep property types", () => {
expect(root).toEqual({ middle: { child: 0 } });
updateObject((o) => o.middle.child, 1);
expect(root).toEqual({ middle: { child: 1 } });
});
If you want to try some of this code for yourself, check out this sample repository.