TypeScript Language Best Practices


Overview


Avoid using any type

Pretty straightforward - any effectively disables type checking 🙂

Handle null & undefined Safely

  • use union type to specify if variables could be null and/or undefined
let e: string | undefined | null;
  • undefined type is sometimes inferred (such as for optional parameters)
function echo(param?: string) { // <- type will be `string | undefined`
    return param;
}
  • use non-strict equality check to test for null and undefined
function echo(param?: string): string {
    if (param != null) return param;
    return '';
}

Avoid Excessive Annotation

  • should try to utilize the TypeScript compiler's ability to infer types
const arr: string[] = ['a', 'b'];

arr.forEach((str: string) => { // <- could annotate callback parameter
    // do something with str...
});

arr.forEach((str) => { // <- still get full type-safety without annotation
    // do something with str...
});

Utilize Intersection Types

  • allow us to create new types by combining existing ones
    • helps us avid type proliferation
    • instead of wide range of similar types, can use small set of base types and intersections
interface Product {
    sku: string;
    description: string;
}

interface Shippable {
    destination: string;
    weight: string;
}

type ShippableProduct = Product & Shippable; // <- name is intersection of names of types

let myProduct: ShippableProduct = {
    sku: 'abc',
    description: 'a product',
    destination: 'somewhere',
    weight: '1kg',
};
  • NOTE: conflicting sub-types will result in never
interface Product {
    sku: string;
    description: string;
}

interface Shippable {
    destination: string;
    weight: string;
}

interface Returnable {
    returnId: string;
    weight: number; // <- this conflicts with `weight: string` from `Shippable`!
}

type ShippableReturnableProduct = Product & Shippable & Returnable; // <- TypeScript will let us create this union without any warnings

let product1: ShippableReturnableProduct = {
    sku: '123abc',
    description: '',
    destination: '',
    returnId: 'abc123',
    weight: '1kg', // <- will throw "Error: type 'string' is not assignable to type 'never'."
}

Use Enums

  • represent series of named values
  • uniquely, they survive compilation process and remain in compiled JavaScript
// Numeric enum with auto-initializing zero-based indices
enum Days {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday,
}

// Numeric enum with explicitly assigned indices
enum Days {
    Monday = 0,
    Tuesday = 1,
    Wednesday = 2,
    Thursday = 3,
    Friday = 4,
    Saturday = 5,
    Sunday = 6,
}

// String enum
enum Days {
    Monday = 'monday',
    Tuesday = 'tuesday',
    Wednesday = 'wednesday',
    Thursday = 'thursday',
    Friday = 'friday',
    Saturday = 'saturday',
    Sunday = 'sunday',
}

Enums similar in some ways to union types and can sometimes be used in similar contexts

type Strs = 'a' | 'b' | 'c';

enum Str {
    A = 'a',
    B = 'b',
    C = 'c',
}

// Example 1
type Strs = 'a' | 'b' | 'c';

function checkString(str: Strs) {
    switch (str) {
        case 'a':
            // etc
    }
}

// Example 2
enum Str {
    A = 'a',
    B = 'b',
    C = 'c',
}

function checkString(str: Str) {
    switch (str) {
        case Str.A:
            // etc
    }
}

Using Tuples for Fixed-length Arrays

Tuples are fixed-length arrays, where each element has specific type

type Coords = [number, number];

type CoordsAndName = [
    number,
    number,
    string,
];

// Can label elements in tuple to better communicate meaning of each element
type Coords = [
    latitude: number,
    longitude: number,
];

Use readonly and ReadonlyArray Types

Since class properties/objects/arrays are mutable by default, can use readonly and ReadonlyArray types to specify whether certain things should be immutable.

  • Class properties should be readonly if they do not need to change after initialization
class Todo {
    constructor(
        public title: string,
        public completed: boolean,
        public readonly id: string
    ) {
        this.id = crypto.randomUUID();
    }

    public updateCompleted(status: boolean) {
        this.completed = status;
    }
}
  • can use readonly modifier (or ReadonlyArray generic type) to make arrays immutable
const immutable: readonly number[] = [1, 2, 3];

const immutable: ReadonlyArray<number> = [1, 2, 3];

Use the Polymorphic this

Fluent API

Polymorphic this

  • this object can refer to different things in different contexts
  • don't need to do anything special to make this polymorphic
class Calculator {
    private result: number = 0;

    public add(num: number): this {
        this.result += num;
        return this;
    }

    public subtract(num: number): this {
        this.result -= num;
        return this;
    }

    public multiply(num: number): this {
        this.result *= num;
        return this;
    }

    public divide(num: number): this {
        this.result /= num;
        return this;
    }

    public reportResult(num: number): number {
        return this.result;
    }
}

Favor Type-guards over Type Assertion

  • type assertion tells TypeScript to treat value as if it were a specific type
class SpecificConfig {
    // ...
}

const config = state.someConfig as SpecificConfig;
  • type-guards offer way to protect against bugs that could be introduced by type assertions
class SpecificConfig {
    // ...
}

function isSpecificType(config: any): config is SpecificConfig {
    return config instanceof SpecificConfig;
}

Make Switch Statements Exhaustive

  • by "exhaustive", we mean "check every possible value of variable being switched on"
  • can utilize default case to handle scenarios that can/should never happen
enum Themes {
    regular = 'regular',
    dark = 'dark',
    highContrast = 'highcontrast',
};

const unhandled = (_: never): never => _;

function switchTheme(theme: Themes) {
    switch (theme) {
        case Themes.regular:
            // handle regular theme
            break;
        case Themes.dark:
            // handle dark theme
            break;
        default:
            /**
             *~ This will throw a compiler error that "Argument of type 'Themes' is
             *~ not assignable to parameter of type 'never'.
             *~ Additionally in your IDE, the type hint will specifically draw attention
             *~ to the unused property from the `Themes` enum, in this case:
             *~ `(parameter) theme: Themes.highContrast`
             */
            return unhandled(theme);
    }
}

Use Utility Types

Use Generics

Use Conditional Types

type NewType = TestType extends ReferenceType ? OutputTypeA : OutputTypeB;
/**
 *~ If `TestType` is assignable to `ReferenceType`
 *~   evaluates as `OutputTypeA`
 *~ Else
 *~   evaluates as `OutputTypeB`
 */

/*------------- Practical example -------------*/
/* Before refactor */
type Log =
    | { type: 'INFO'; message: string; }
    | { type: 'ERROR'; message: string; error: Error; };

function logIt(log: Log): void {
    switch (log.type) {
        case 'INFO':
            console.log(log.message);
            break;
        case 'ERROR':
            console.log(log.message, log.error.stack);
    }
}

logIt({ type: 'INFO', message: 'I love lamp' });

/* After refactor */
type Log =
    | { type: 'INFO'; message: string; }
    | { type: 'ERROR'; message: string; error: Error; };

type LogType = Log['type'];
type ExtractNonTypeParams<L, T> = L extends { type: T } ? Omit<L, 'type'> : never;

function logIt<T extends LogType>(
    logType: T,
    args: ExtractNonTypeParams<Log, T>
): void {
    switch (logType) {
        case 'INFO':
            console.log(args.message);
            break;
        case 'ERROR':
            console.log(args.message, (args as ExtractNonTypeParams<Log, "ERROR">).error.stack);
    }
}

Use Decorators

  • allow us to modify behavior of class without changing class itself
  • TypeScript <5 used different spec (stage 2)
    • requires enabling with experimentalDecorators option
  • TypeScript >5 aligns with ECMA spec (stage 3)
  • references:
function instrument<T extends { new (..args: any[]): object }>(target: T) {
    const originalConstructor = target.prototype.constructor;

    for (const key of Object.getOwnPropertyNames(originalConstructor.prototype)) {
        const descriptor = Object.getOwnPropertyDescriptor(originalConstructor.prototype, key);

        if (descriptor != null && typeof descriptor.value === 'function') {
            const originalFunction = descriptor.value;

            descriptor.value = function(...args: any[]) {
                console.log(`Calling method '${key}' with arguments: ${args.join(', ')}`);
                const result = originalFunction.apply(this, args);

                if (result != null) {
                    console.log(`Method '${key}' returned: ${result}`);
                }

                return result;
            };

            Object.defineProperty(originalConstructor, key, descriptor);
        }

    }
}

@instrument
class Todo {
    // ...
}
Made with Gatsby G Logo