SuiteScript + TypeScript + Decorators = ❤️

I've been using TypeScript to write my SuiteScripts for the last 6 years and haven't looked back. The type system provided by TypeScript reduces risk, makes refactoring easier, and prevents silly mistakes we've all made when dealing with dynamically-typed languages like JavaScript. If you aren't already writing your SuiteScripts in TypeScript, you should check out this Oracle blog post that provides an excellent "getting started" guide: Getting Started with SuiteScript 2.1, TypeScript, and the SuiteCloud Development Framework (SDF) (oracle.com).

More recently though, I've started exploring ways to leverage TypeScript to reduce the number of lines like this in my scripts:

const companyName = record.getValue("companyName");

Sure, there's nothing egregious about this line on its own. But we're missing out on all of the benefits of TypeScript here without also adding additional type annotations (and let's be honest, I can't expect my team to do that consistently if I can't do it myself).

Strongly-Typed Record Classes

My first foray into "strongly-typed records" was to simply create a class that accepted a NetSuite Record object in the constructor. I then exposed each field as a getter and setter pair:

// Customers/CustomerLibrary.ts
/**
 * @NApiVersion 2.1
 */

import * as NRecord from "N/record";

export class Customer {
    constructor(private readonly _data: NRecord.Record) { }

    get id(): number {
        return this._data.id;
    }

    get companyName(): string {
        return this._data.getValue("companyName");
    }

    set companyName(value: string) {
        this._data.setValue("companyName", value);
    }

    save(): void {
        this._data.save();
    }
}

I was then able to use this class from, say, a user event script like so:

// Customers/CustomerUserEventScript.ts
/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */

import { debug } from "N/log";
import { EntryPoints } from "N/types";
import { Customer } from "./CustomerLibrary";

export function afterSubmit(context: EntryPoints.UserEvent.afterSubmitContext): void {
    const customer = new Customer(context.newRecord);

    debug("Customer Name", customer.companyName);

    customer.companyName = "Dunder Mifflin";

    customer.save();
}

Great! Now my entry point scripts are much cleaner. But writing the getters and setters is pretty tedious and there in an opportunity to move some of the common members to an abstract class.

Using Decorators

Enter decorators. These seemed, at least to me, to be pretty intimidating at first. But once it clicked, I realized they might just be the answer to my remaining annoyance with my "strongly-typed records" approach.

For a full explanation on the use of ECMAScript decorators in TypeScript, there is a great set of examples in the TypeScript 5.0 announcement post: Announcing TypeScript 5.0 - TypeScript (microsoft.com). But for the sake of brevity, a decorator is simply a function that accepts metadata describing a class member and returns an alternate implementation of the member (again, horribly simplified, but all you need to know for this article).

So how can this help us? Well, imagine if we could do something like this when defining our Customer class from before:

// Customers/CustomerLibrary.ts
/**
 * @NApiVersion 2.1
 */

import * as NRecord from "N/record";
import { AutoGetSet, StronglyTypedRecord } from "../Shared/RecordLibrary";

export class Customer extends StronglyTypedRecord {
    constructor(data: NRecord.Record) {
        super(data);
    }

    @AutoGetSet()
    accessor companyName: string = "";
}

So much more concise! And look at that - no magic strings, either! Let's take a look at the full RecordLibrary.ts file to see what is happening:

// Shared/RecordLibrary.ts
/**
 * @NApiVersion 2.1
 */

import * as NRecord from "N/record";

export type RecordLike = (NRecord.Record | NRecord.ClientCurrentRecord);

/**
 * The base class that all strongly-typed record classes should extend.
 */
export abstract class StronglyTypedRecord {
    constructor(private readonly _data: RecordLike) { }

    get id(): number {
        return this._data.id ?? 0;
    }

    get type(): string | NRecord.Type {
        return this._data.type;
    }

    /**
     * Convenience function that saves the underlying NetSuite Record.
     * This function can only be used if the instance was initialized using a NetSuite Server-Side record.
     * 
     * @governance 20 units for transactions, 4 for custom records, 10 for all other records
     */
    save(): void {
        if (!isSavableNetSuiteRecord(this._data)) {
            throw NError.create({
                name: "NOT_A_SAVABLE_RECORD",
                message: "This instance cannot be saved because the underlying record instance does not have a \"save\" function. This typically means it is a \"current\" record and will be saved by NetSuite."
            });
        }

        this._data.save();
    }

    protected getData(): RecordLike {
        return this._data;
    }

    protected getText(key: string): string | string[] {
        if (key.toLowerCase() === "id" && "id" in this._data) {
            return this._data.id?.toString() as string;
        }

        else if (key.toLowerCase() === "type" && "type" in this._data) {
            return this._data.type?.toString() as string;
        }

        else {
            return this._data.getText(key);
        }
    }

    protected getValue<T>(key: string): T | null {
        if (key.toLowerCase() === "id" && "id" in this._data) {
            return this._data.id as T;
        }

        else if (key.toLowerCase() === "type" && "type" in this._data) {
            return this._data.type as T;
        }

        else {
            return (this._data.getValue(key)?.valueOf() ?? null) as T | null;
        }
    }

    protected setText(key: string, value: string): void {
        if (key.toLowerCase() === "id" || key.toLowerCase() === "type") {
            throw NError.create({
                name: "READONLY_FIELD",
                message: `This field (ID: ${key}) is readonly and cannot be modified.`
            });
        }

        this._data.setText(key, value);
    }

    protected setValue<T extends NRecord.FieldValue>(key: string, value: T): void {
        if (key.toLowerCase() === "id" || key.toLowerCase() === "type") {
            throw NError.create({
                name: "READONLY_FIELD",
                message: `This field (ID: ${key}) is readonly and cannot be modified.`
            });
        }

        this._data.setValue(key, value);
    }
}

export function isSavableNetSuiteRecord(obj: NRecord.Record | NRecord.ClientCurrentRecord): obj is NRecord.Record {
    return "save" in obj;
}

export type AutoGetSetDecorator = (accessor: ClassAccessorDecoratorTarget<any, any>, context: ClassAccessorDecoratorContext<StronglyTypedRecord<any>, any>) => ClassAccessorDecoratorResult<any, any>;

export interface AutoGetSetOptions {
    /**
     * When set to true, the "getText" and "setText" methods will be used on the underlying NetSuite Record API to get and set the values for this property.
     * If false or omitted, the "getValue" and "setValue" methods will be used instead.
     */
    asText?: boolean;
}

/**
 * When applied to an accessor on a class that extends StronglyTypedRecord, this decorator automatically implements the getter and setter for the property.
 */
export function AutoGetSet(options?: AutoGetSetOptions): AutoGetSetDecorator {
    return function(accessor: ClassAccessorDecoratorTarget<any, any>, context: ClassAccessorDecoratorContext<StronglyTypedRecord, any>): ClassAccessorDecoratorResult<any, any> {        
        const getter = function (this: StronglyTypedRecord) {
            return (options?.asText)
                ? this.getText(context.name.toString())
                : this.getValue(context.name.toString());
        };

        const setter = function (this: StronglyTypedRecord, value: any) {
            if (options?.asText) {
                this.setText(context.name.toString(), value);
            }

            else {
                this.setValue(context.name.toString(), value);
            }
        };

        return {
            get: getter,
            set: setter
        };
    };
}

There at the bottom of the file is the decorator. You'll note we also added the ability to specify which underlying method should be called (getText vs getValue) using a parameter in the decorator. In fact, you can add all sorts of interesting features with this approach such as adding the ability to override the field ID when you need access to both the text and "native" value for a field:

@AutoGetSet({ fieldId: "custentity_my_record" })
accessor myRecordId: number | null = null;

@AutoGetSet({ fieldId: "custentity_my_record", asText: true })
accessor myRecordName: string | null = null;

This approach has not only improved the type safety and our ability to refactor, but it also incidentally has allowed us to move all of our business logic into these strongly-typed record classes making it easier to share across various entry point scripts and maintain.