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.