Development

TypeScript for Dynamics 365 Client-Side Scripting

January 14, 2025
CRX Partners Team
9 min read

Why TypeScript for Dynamics 365?

TypeScript brings static type checking, modern JavaScript features, and excellent IDE support to Dynamics 365 client-side development. While Dynamics 365 runs JavaScript, developing in TypeScript catches errors at compile time, provides IntelliSense for the Xrm API, and makes code more maintainable. Client API Reference.

Benefits of TypeScript

  • Type Safety: Catch errors before runtime
  • IntelliSense: Full autocomplete for Xrm APIs
  • Refactoring: Safe rename and restructure operations
  • Documentation: Types serve as inline documentation
  • Modern Features: Use async/await, classes, modules

Project Setup

Initialize TypeScript Project

# Create project folder
mkdir D365ClientScripts
cd D365ClientScripts

# Initialize npm and TypeScript
npm init -y
npm install typescript --save-dev
npm install @types/xrm --save-dev

# Initialize TypeScript configuration
npx tsc --init

TypeScript Configuration

Configure tsconfig.json for Dynamics 365 development:

{
  "compilerOptions": {
    "target": "ES2017",
    "module": "ES2020",
    "lib": ["ES2017", "DOM"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true,
    "types": ["xrm"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Project Structure

D365ClientScripts/
├── src/
│   ├── forms/
│   │   ├── account.form.ts
│   │   ├── contact.form.ts
│   │   └── opportunity.form.ts
│   ├── ribbons/
│   │   └── account.ribbon.ts
│   ├── common/
│   │   ├── notifications.ts
│   │   └── validation.ts
│   └── types/
│       └── custom-types.d.ts
├── dist/                    # Compiled JavaScript output
├── package.json
└── tsconfig.json

Form Scripts

Basic Form Event Handlers

Create typed form event handlers with proper Xrm context:

// src/forms/account.form.ts
export namespace AccountForm {

    // Form OnLoad event handler
    export function onLoad(executionContext: Xrm.Events.EventContext): void {
        const formContext = executionContext.getFormContext();

        // Set up field change handlers
        const nameAttribute = formContext.getAttribute("name");
        if (nameAttribute) {
            nameAttribute.addOnChange(onNameChange);
        }

        // Check form type
        const formType = formContext.ui.getFormType();
        if (formType === XrmEnum.FormType.Create) {
            setDefaultValues(formContext);
        }

        // Initialize business logic
        validateRequiredFields(formContext);
    }

    // Form OnSave event handler
    export function onSave(executionContext: Xrm.Events.SaveEventContext): void {
        const formContext = executionContext.getFormContext();
        const saveEventArgs = executionContext.getEventArgs();

        // Validate before save
        if (!validateBeforeSave(formContext)) {
            saveEventArgs.preventDefault();
            return;
        }

        // Custom save logic
        console.log("Account saved successfully");
    }

    // Field change handler
    function onNameChange(executionContext: Xrm.Events.EventContext): void {
        const formContext = executionContext.getFormContext();
        const nameAttribute = formContext.getAttribute("name");

        if (nameAttribute) {
            const nameValue = nameAttribute.getValue();
            // Perform validation or dependent logic
            if (nameValue && nameValue.length > 100) {
                formContext.ui.setFormNotification(
                    "Account name is very long. Consider shortening it.",
                    "WARNING",
                    "nameWarning"
                );
            } else {
                formContext.ui.clearFormNotification("nameWarning");
            }
        }
    }

    function setDefaultValues(formContext: Xrm.FormContext): void {
        // Set default account type
        const accountTypeAttr = formContext.getAttribute("accountcategorycode");
        if (accountTypeAttr && !accountTypeAttr.getValue()) {
            accountTypeAttr.setValue(1); // Standard
        }
    }

    function validateRequiredFields(formContext: Xrm.FormContext): boolean {
        let isValid = true;
        const requiredFields = ["name", "telephone1", "emailaddress1"];

        requiredFields.forEach(fieldName => {
            const attribute = formContext.getAttribute(fieldName);
            const control = formContext.getControl(fieldName) as Xrm.Controls.StandardControl;

            if (attribute && !attribute.getValue()) {
                if (control) {
                    control.setNotification("This field is required", fieldName + "_required");
                }
                isValid = false;
            }
        });

        return isValid;
    }

    function validateBeforeSave(formContext: Xrm.FormContext): boolean {
        // Clear previous notifications
        formContext.ui.clearFormNotification("saveValidation");

        // Perform validation
        const emailAttr = formContext.getAttribute("emailaddress1");
        if (emailAttr) {
            const email = emailAttr.getValue();
            if (email && !isValidEmail(email)) {
                formContext.ui.setFormNotification(
                    "Please enter a valid email address",
                    "ERROR",
                    "saveValidation"
                );
                return false;
            }
        }

        return true;
    }

    function isValidEmail(email: string): boolean {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email);
    }
}

Working with Lookups

// Get and set lookup values
export function handlePrimaryContactChange(
    executionContext: Xrm.Events.EventContext
): void {
    const formContext = executionContext.getFormContext();

    // Get lookup value
    const primaryContactAttr = formContext.getAttribute("primarycontactid");
    if (!primaryContactAttr) return;

    const lookupValue = primaryContactAttr.getValue();
    if (lookupValue && lookupValue.length > 0) {
        const contactId = lookupValue[0].id;
        const contactName = lookupValue[0].name;
        const entityType = lookupValue[0].entityType;

        console.log(`Selected contact: ${contactName} (${contactId})`);

        // Fetch additional contact details
        fetchContactDetails(formContext, contactId);
    }
}

async function fetchContactDetails(
    formContext: Xrm.FormContext,
    contactId: string
): Promise<void> {
    try {
        const result = await Xrm.WebApi.retrieveRecord(
            "contact",
            contactId,
            "?$select=emailaddress1,telephone1,jobtitle"
        );

        // Update form fields with contact info
        const emailAttr = formContext.getAttribute("emailaddress1");
        if (emailAttr && result.emailaddress1) {
            emailAttr.setValue(result.emailaddress1);
        }
    } catch (error) {
        console.error("Error fetching contact details:", error);
    }
}

// Set lookup value programmatically
export function setParentAccount(
    formContext: Xrm.FormContext,
    accountId: string,
    accountName: string
): void {
    const parentAccountAttr = formContext.getAttribute("parentaccountid");
    if (parentAccountAttr) {
        const lookupValue: Xrm.LookupValue[] = [{
            id: accountId,
            name: accountName,
            entityType: "account"
        }];
        parentAccountAttr.setValue(lookupValue);
    }
}

Ribbon Commands

Enable Rules and Commands

// src/ribbons/account.ribbon.ts
export namespace AccountRibbon {

    // Enable rule - determines if button is enabled
    export function enableApproveButton(
        primaryControl: Xrm.FormContext
    ): boolean {
        // Check form type - only enable on existing records
        if (primaryControl.ui.getFormType() === XrmEnum.FormType.Create) {
            return false;
        }

        // Check status
        const statusAttr = primaryControl.getAttribute("statuscode");
        if (statusAttr) {
            const status = statusAttr.getValue();
            // Only enable for "Pending Approval" status
            return status === 100000001;
        }

        return false;
    }

    // Visibility rule
    export function showForManagers(
        primaryControl: Xrm.FormContext
    ): boolean {
        // Check user's security roles
        const userRoles = Xrm.Utility.getGlobalContext().userSettings.roles;
        const managerRoleId = "00000000-0000-0000-0000-000000000001"; // Replace with actual role ID

        return userRoles.get().some(role => role.id === managerRoleId);
    }

    // Command action
    export async function approveAccount(
        primaryControl: Xrm.FormContext
    ): Promise<void> {
        const recordId = primaryControl.data.entity.getId().replace(/[{}]/g, '');

        // Confirm action
        const confirmResult = await Xrm.Navigation.openConfirmDialog({
            title: "Approve Account",
            text: "Are you sure you want to approve this account?",
            confirmButtonLabel: "Approve",
            cancelButtonLabel: "Cancel"
        });

        if (!confirmResult.confirmed) return;

        try {
            // Update status
            await Xrm.WebApi.updateRecord("account", recordId, {
                statuscode: 100000002 // Approved
            });

            // Show success message
            await Xrm.Navigation.openAlertDialog({
                title: "Success",
                text: "Account has been approved."
            });

            // Refresh the form
            primaryControl.data.refresh(false);

        } catch (error) {
            console.error("Error approving account:", error);
            await Xrm.Navigation.openAlertDialog({
                title: "Error",
                text: "Failed to approve account. Please try again."
            });
        }
    }

    // Command with selected records from grid
    export async function bulkApprove(
        selectedEntityReferences: Xrm.LookupValue[]
    ): Promise<void> {
        if (!selectedEntityReferences || selectedEntityReferences.length === 0) {
            await Xrm.Navigation.openAlertDialog({
                text: "Please select at least one record."
            });
            return;
        }

        const confirmResult = await Xrm.Navigation.openConfirmDialog({
            title: "Bulk Approve",
            text: `Are you sure you want to approve ${selectedEntityReferences.length} account(s)?`
        });

        if (!confirmResult.confirmed) return;

        let successCount = 0;
        for (const record of selectedEntityReferences) {
            try {
                await Xrm.WebApi.updateRecord("account", record.id, {
                    statuscode: 100000002
                });
                successCount++;
            } catch (error) {
                console.error(`Error approving ${record.id}:`, error);
            }
        }

        await Xrm.Navigation.openAlertDialog({
            text: `Successfully approved ${successCount} of ${selectedEntityReferences.length} accounts.`
        });
    }
}

Common Utilities

Notification Helper

// src/common/notifications.ts
export namespace NotificationHelper {

    export function showFormError(
        formContext: Xrm.FormContext,
        message: string,
        notificationId: string = "formError"
    ): void {
        formContext.ui.setFormNotification(message, "ERROR", notificationId);
    }

    export function showFormWarning(
        formContext: Xrm.FormContext,
        message: string,
        notificationId: string = "formWarning"
    ): void {
        formContext.ui.setFormNotification(message, "WARNING", notificationId);
    }

    export function showFormInfo(
        formContext: Xrm.FormContext,
        message: string,
        notificationId: string = "formInfo"
    ): void {
        formContext.ui.setFormNotification(message, "INFO", notificationId);
    }

    export function clearNotification(
        formContext: Xrm.FormContext,
        notificationId: string
    ): void {
        formContext.ui.clearFormNotification(notificationId);
    }

    export function setFieldError(
        formContext: Xrm.FormContext,
        fieldName: string,
        message: string
    ): void {
        const control = formContext.getControl(fieldName) as Xrm.Controls.StandardControl;
        if (control && control.setNotification) {
            control.setNotification(message, fieldName + "_error");
        }
    }

    export function clearFieldError(
        formContext: Xrm.FormContext,
        fieldName: string
    ): void {
        const control = formContext.getControl(fieldName) as Xrm.Controls.StandardControl;
        if (control && control.clearNotification) {
            control.clearNotification(fieldName + "_error");
        }
    }
}

Building and Deployment

Build Scripts

// package.json scripts
{
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch",
    "bundle": "webpack --config webpack.config.js",
    "deploy": "npm run build && pac webresource push"
  }
}

Webpack Configuration (Optional)

Bundle multiple files into a single JavaScript file:

// webpack.config.js
const path = require('path');

module.exports = {
  entry: {
    'account.form': './src/forms/account.form.ts',
    'contact.form': './src/forms/contact.form.ts',
    'account.ribbon': './src/ribbons/account.ribbon.ts'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
    library: {
      type: 'var',
      name: '[name]'
    }
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  },
  mode: 'production'
};

Best Practices

  • Use Namespaces: Organize code by entity and purpose
  • Enable Strict Mode: Catch potential errors at compile time
  • Type Everything: Avoid 'any' type; create custom interfaces
  • Handle Nulls: Use optional chaining and null checks
  • Async/Await: Use modern async patterns for Web API calls
  • Centralize Common Logic: Create shared utilities for notifications, validation
  • Document Public Functions: Use JSDoc comments for exported functions

Conclusion

TypeScript significantly improves the Dynamics 365 client-side development experience. With proper typing, you catch errors before deployment, write more maintainable code, and benefit from excellent IDE support. Combined with modern build tools, TypeScript creates a professional development workflow that scales with your project complexity.

Need Help with Your Dynamics 365 Project?

Our team of experts can help you implement TypeScript best practices in your Dynamics 365 projects.