5 Tricks To Make The Most Out Of Typescript

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 properties
  • ObjProxyArg<RootObject, T> – indicates that we will be accessing a property on a RootObject, but we are not sure what property it will be (hence the generic T 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 that context will determine its type based on the property requested using ObjProxyArg<RootObject, T>
  • set – a method from ts-object-path that allows a base object to be updated depending on the proxy and context

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.

Leave a Reply

Your email address will not be published. Required fields are marked *