Skip to content

Parameters as variables

Templates take values from outside: a VPC id, a list of subnets, a stage name. In plain CloudFormation each is a string you type twice, once in Parameters and again in every {"Ref": "VpcId"}. Misspell one and nothing complains until deploy.

cfn_typed.Params makes the parameter a variable. You declare it once and get back the Ref to use:

params = cfn.Params()
vpc_id = params.ssm("VpcId", "/platform/network/vpc-id")   # a plain {"Ref": "VpcId"}
...
template["Parameters"] = params.declarations()

vpc_id is an ordinary Ref dict, so it is valid anywhere an Expr[str] is accepted. Misspelling the variable is a name error the editor shows immediately, and the string "VpcId" appears exactly once.

Plain and SSM parameters

Call Declares
params.string("Stage", default="dev", allowed_values=[...]) a String parameter; also allowed_pattern, min_length, max_length, constraint_description
params.string("Ami", type="AWS::EC2::Image::Id") a string of an AWS-specific type, which CloudFormation validates (AWS::EC2::VPC::Id, AWS::EC2::KeyPair::KeyName, ...)
params.number("Size", default=3) a Number parameter; also min_value, max_value, constraint_description
params.list_of("Cidrs", default=["10.0.0.0/8"]) a CommaDelimitedList; the Ref is accepted wherever a list is
params.list_of("Subnets", type="AWS::EC2::Subnet::Id") a List<AWS::EC2::Subnet::Id>; type is the element type, so Number gives List<Number>
params.ssm("VpcId", "/path") an SSM String, resolved by CloudFormation at deploy time
params.ssm("Ami", "/path", type="AWS::EC2::Image::Id") an SSM value of that type, as in AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
params.ssm_list("Subnets", "/path") an SSM StringList; type= sets the element type, as for list_of

SSM values are not read when you build the template. The template carries only the parameter's name in SSM, and CloudFormation looks the value up when the stack is created or updated and shows it in the change set. That keeps template generation a pure function of its inputs, which is the property the rest of this book relies on.

Names must be letters and digits only (a CloudFormation rule), and Params checks that. Declaring the same name twice with the same settings is fine and returns the same Ref; declaring it differently raises, so two modules cannot quietly disagree.

A shared "defaults" library

Organisations usually want one place that knows where the shared network lives, which tags every stack carries, and so on. That belongs in your own package, not in a public one. Each default is a function that takes the registry and returns variables:

from typing import NamedTuple

from cfn_typed import Expr, Params, Tag, tags
from cfn_typed.intrinsics import Ref


class Network(NamedTuple):
    vpc_id: Ref
    private_subnets: Ref


def network(params: Params) -> Network:
    """The shared network, read from SSM by CloudFormation at deploy time."""
    return Network(
        vpc_id=params.ssm("VpcId", "/platform/network/vpc-id", description="Shared VPC"),
        private_subnets=params.ssm_list("PrivateSubnets", "/platform/network/private-subnets"),
    )


def standard_tags(params: Params) -> list[Expr[Tag]]:
    stage = params.string("Stage", default="dev", allowed_values=["dev", "test", "prod"])
    return tags(Stage=stage, ManagedBy="cfn-typed")

A stack calls only what it needs:

def build() -> cfn.Template:
    params = cfn.Params()
    net = network(params)  # only what is called gets declared
    tags = standard_tags(params)
    return {
        "AWSTemplateFormatVersion": "2010-09-09",
        "Parameters": params.declarations(),
        "Resources": web_entrypoint(
            "Web",
            vpc_id=net.vpc_id,
            subnets=net.private_subnets,
            ports=[8080],
            allowed_cidrs=["10.0.0.0/8"],
            scheme="internal",
            tags=tags,
        ),
    }
  • Only what is called gets declared. A stack that never calls network(params) does not get VpcId or PrivateSubnets in its Parameters, so unused defaults never clutter a template or count against its parameter limit.
  • The result is a typed bundle. net.vpc_id and net.private_subnets autocomplete; there is no string to mistype.
  • The paths live in one place. When the SSM path moves, one line in the defaults package changes and every stack follows on its next deploy.
  • Nothing organisation-specific is in cfn-typed. It supplies the mechanism (Params); your defaults package supplies the names and paths.

The full rendered stack has the parameters first, and every use is !Ref VpcId, !Ref PrivateSubnets:

Parameters:
  VpcId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /platform/network/vpc-id
  PrivateSubnets:
    Type: AWS::SSM::Parameter::Value<List<String>>
    Default: /platform/network/private-subnets

Values from other stacks

A value exported by another stack needs no registry, because Fn::ImportValue is not declared anywhere. The variable is simply a module-level constant, defined once:

from cfn_typed import import_value

shared_alb_arn = import_value("platform-alb-arn")   # import this name everywhere

Dynamic references and secrets

cfn.dynamic_ssm("/app/key") returns the string {{resolve:ssm:/app/key}}, and dynamic_ssm("/app/password", secure=True) the ssm-secure form. These need no template parameter, but CloudFormation accepts them only in some properties, and the secure form in fewer still, so check the resource's documentation before relying on one. For anything secret, prefer a managed secret and refer to it rather than passing a value through the template.

What stays a string

A logical ID in ref("OrderQueue") or get_att(...) is still a string, because it names a resource rather than a parameter. Build those from one name inside a pattern, as the other pages do, and keep cfn-lint in CI.