typescriptaws-cdk

Type-safe method for tracking dynamically exported parameters in base/derived classes


I'm working on an AWS CDK project using TypeScript and I'm trying to improve type safety for a parameter tracking system I've implemented. I have a base class that allows child stacks to export parameters, and I want to ensure type safety when accessing these parameters later in my code. Here's a simplified version of my current code:

// BaseStack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';

export interface BaseStackProps extends cdk.StackProps {
  // Base props
}

export class BaseStack extends cdk.Stack {
  // Used to export parameters for cross-region references
  exportedParameters = {}

  constructor(scope: Construct, id: string, props: BaseStackProps) {
    super(scope, id, props);
  }

  exportParameter(name: string, value: string) {
    new StringParameter(this, name, {
      parameterName: name,
      stringValue: value
    });

    this.exportedParameters[name] = name;
  }
}

// StackA.ts
export class StackA extends BaseStack {
  constructor(scope: Construct, id: string, props: BaseStackProps) {
    super(scope, id, props);
    
    // some custom resources
    this.exportParameter("specialParameter", "value");
  }
}

// app.ts
const stackA = new StackA(this, "stackA", {});

// Problem: I want TypeScript to catch this error:
stackA.exportedParameters.nonExistentParameter; // No TypeScript error, but would fail at runtime
// And I want autocomplete to show me the available parameters:
stackA.exportedParameters.specialParameter; // Should work and be suggested by IntelliSense

What I'm looking for:

Is there a proper TypeScript pattern to achieve this kind of type safety for dynamically added properties in inherited classes?


Solution

  • As with many cases I suggest to avoid using constructors and use factories rather, that gives much more flexibility to typing:

    Playground

    export class BaseStack<T extends Record<string, StringParameter> = {}> extends cdk.Stack {
      // Used to export parameters for cross-region references
      exportedParameters = {} as T;
    
      protected constructor(scope: Construct, id: string, props: BaseStackProps) {
        super(scope, id, props);
      }
    
      static create(scope: Construct, id: string, props: BaseStackProps){
        return new this(scope, id, props);
      }
    
      exportParameter<N extends string>(name: N, value: string) {
    
        const param = new StringParameter(this, name, {
          parameterName: name,
          stringValue: value
        });
    
        (this as any).exportedParameters[name] = value; // you could find a better solution
        return this as this & BaseStack<{[k in N]: typeof param}>;
      }
    }
    
    // StackA.ts
    export class StackA extends BaseStack {
    
      static create(scope: Construct, id: string, props: BaseStackProps){
        const out = new StackA(scope, id, props);
        return out
          .exportParameter("specialParameter", "value")
          .exportParameter("specialParameter2", "value2");
      }
    
    }
    
    // app.ts
    const stackA = StackA.create(this, "stackA", {});