Problem Context

Infrastructure code is just code โ€” it deserves the same quality standards as your application code: type safety, refactoring support, documented interfaces, and testability. But in YAML, HCL, and JSON, your editor can't tell you when you pass a number where a string is expected, or when you reference a property that doesn't exist.

Pulumi with TypeScript applies static typing to cloud infrastructure. Every Azure resource type has a generated TypeScript interface. Mismatched properties, missing required inputs, and invalid configurations become compile errors โ€” not runtime surprises after a failed deployment.

๐Ÿค” Sound familiar?
  • A typo in an ARM template property name caused a silent misconfiguration
  • You want to use TypeScript generics to model environment-specific infrastructure variations
  • Your team is already TypeScript-fluent and wants one less language to maintain
  • You need to test infrastructure logic with Jest before spending deploy time

Pulumi TypeScript: your infrastructure gets the same compiler guarantees as your application code.

Project Setup

# Create a new TypeScript Azure project
pulumi new azure-typescript --name myapp --stack dev

# Project structure
myapp/
โ”œโ”€โ”€ index.ts              # Infrastructure entry point
โ”œโ”€โ”€ Pulumi.yaml           # Project metadata
โ”œโ”€โ”€ Pulumi.dev.yaml       # Stack config
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ tsconfig.json
โ””โ”€โ”€ node_modules/
// package.json (key sections)
{
  "devDependencies": {
    "@pulumi/pulumi": "^3.0.0",
    "@pulumi/azure-native": "^2.0.0",
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0"
  }
}

Type-Safe Resource Declarations

// index.ts
import * as pulumi from '@pulumi/pulumi';
import * as azure from '@pulumi/azure-native';

const config = new pulumi.Config();
const environment = config.require('environment');
const location = config.get('location') ?? 'westus2';

// TypeScript knows every valid property and its type
const rg = new azure.resources.ResourceGroup(`rg-${environment}`, {
  resourceGroupName: `rg-myapp-${environment}`,
  location,
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
});

const storage = new azure.storage.StorageAccount(`sa-${environment}`, {
  accountName: `samyapp${environment}001`,
  resourceGroupName: rg.name,
  location: rg.location,
  sku: { name: azure.storage.SkuName.Standard_LRS },
  kind: azure.storage.Kind.StorageV2,
  allowBlobPublicAccess: false,          // TypeScript: boolean, not string
  minimumTlsVersion: azure.storage.MinimumTlsVersion.TLS1_2,
  tags: rg.tags,
});

export const storageAccountName = storage.name;
export const blobEndpoint = storage.primaryEndpoints.apply(ep => ep.blob);

Working with Output<T>

Resources return Output<T>for values resolved after deployment. TypeScript's type system tracks this for you:

import * as pulumi from '@pulumi/pulumi';

// Output<string> โ€” can't use directly as a string
const name: pulumi.Output<string> = storage.name;

// Transform with .apply()
const connectionString: pulumi.Output<string> = storage.name.apply(
  n => `DefaultEndpointsProtocol=https;AccountName=${n};...`
);

// Combine multiple outputs
const message: pulumi.Output<string> = pulumi.interpolate`
  Storage account ${storage.name} deployed in ${rg.location}
`;

// all() โ€” wait for multiple outputs
const summary = pulumi.all([storage.name, rg.location]).apply(
  ([name, loc]) => `${name} @ ${loc}`
);

ComponentResource: Typed Infrastructure Modules

import * as pulumi from '@pulumi/pulumi';
import * as azure from '@pulumi/azure-native';

interface AppStorageArgs {
  resourceGroupName: pulumi.Input<string>;
  location: pulumi.Input<string>;
  environment: string;
  replicationEnabled?: boolean;
}

class AppStorage extends pulumi.ComponentResource {
  public readonly accountName: pulumi.Output<string>;
  public readonly blobEndpoint: pulumi.Output<string | undefined>;

  constructor(
    name: string,
    args: AppStorageArgs,
    opts?: pulumi.ComponentResourceOptions,
  ) {
    super('myapp:storage:AppStorage', name, {}, opts);

    const sku = args.replicationEnabled
      ? azure.storage.SkuName.Standard_GRS
      : azure.storage.SkuName.Standard_LRS;

    const account = new azure.storage.StorageAccount(
      `${name}-sa`,
      {
        accountName: `sa${name}${args.environment}001`.toLowerCase(),
        resourceGroupName: args.resourceGroupName,
        location: args.location,
        sku: { name: sku },
        kind: azure.storage.Kind.StorageV2,
        allowBlobPublicAccess: false,
        minimumTlsVersion: azure.storage.MinimumTlsVersion.TLS1_2,
      },
      { parent: this },
    );

    this.accountName = account.name;
    this.blobEndpoint = account.primaryEndpoints.apply(ep => ep.blob);

    this.registerOutputs({
      accountName: this.accountName,
      blobEndpoint: this.blobEndpoint,
    });
  }
}

// Usage
const storage = new AppStorage('app', {
  resourceGroupName: rg.name,
  location: rg.location,
  environment: 'prod',
  replicationEnabled: true,
});

export const blobEndpoint = storage.blobEndpoint;

Testing with Jest

// __tests__/storage.test.ts
import * as pulumi from '@pulumi/pulumi';

// Mock provider
pulumi.runtime.setMocks({
  newResource: (args) => ({
    id: args.inputs.accountName + '_id',
    state: args.inputs,
  }),
  call: (args) => ({ result: args.inputs }),
});

// Import after setting mocks
import '../index';

describe('Storage Account Security', () => {
  test('public access is disabled', done => {
    const infra = require('../index');

    infra.storageAccount.allowBlobPublicAccess.apply(
      (allowPublic: boolean) => {
        expect(allowPublic).toBe(false);
        done();
      }
    );
  });

  test('TLS version is 1.2', done => {
    const infra = require('../index');

    infra.storageAccount.minimumTlsVersion.apply(
      (tls: string) => {
        expect(tls).toBe('TLS1_2');
        done();
      }
    );
  });
});

Stack References: Cross-Stack Outputs

// Consume outputs from another stack
const networkStack = new pulumi.StackReference(
  'org/network/prod'  // or 'org/network/${environment}'
);

const vnetId = networkStack.getOutput('vnetId') as pulumi.Output<string>;
const subnetId = networkStack.getOutput('appSubnetId') as pulumi.Output<string>;

// Use in this stack's resources
const appService = new azure.web.WebApp('app', {
  virtualNetworkSubnetId: subnetId,
  // ...
});

Production Checklist

  • Enable strict: true in tsconfig.json โ€” catch null/undefined issues at compile time
  • Type all ComponentResource args interfaces โ€” avoid any
  • Write Jest tests for security-critical properties (public access, TLS, encryption)
  • Use pulumi.interpolate for string templates that include Output<T>
  • Pin @pulumi/azure-native to a specific minor version; review provider changelogs before upgrading
  • Use ResourceOptions.protect: true on databases and state stores
  • Run pulumi preview in CI on every pull request; require approval for production deploys