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.