This post introduces typescript features which can be added to some code in order to make it more typesafe.
The final code uses type literals to ensure maximum type safety as well as typeguard functions to enable an object factory. It also uses the 'companion object pattern' (which will be explained below) to generate composite types as an alternative approach to using classes.
The final code is intended to illustrate a design pattern.
The full code is here: https://github.com/nhanlon2/CompanionObjectPattern
We start with some code in a typescript file which is setting up some properties to be passed into the constructor of a class. We use this code in our AWS CDK code to create S3 buckets. We need to create multiple instances of the properties and then multiple instances of the class, modelling various distinct types of S3 bucket which we currently use.
For the sake of brevity, here are two of the property types (there are actually 4 types in total)
const devBucketStackProps: RoSBucketProps[] = [{
bucketName: 'ros-type1-dev',
bucketEncryption: BucketEncryption.KMS_MANAGED,
putBucketEncryption: "aws:kms",
versioned: true,
lifecycleRules: [],
}, {
bucketName: 'ros-type2-dev',
bucketEncryption: BucketEncryption.KMS_MANAGED,
putBucketEncryption: "aws:kms",
versioned: true,
lifecycleRules: [],
}
];
There is actually an interface, RoSBucketProps which defines the shape of a property.
export interface RoSBucketProps {
readonly bucketName: string;
readonly bucketEncryption: BucketEncryption;
readonly putBucketEncryption: string;
readonly versioned: boolean;
}
However, we have not typed the properties beyond that. With 5 properties that each can vary and 4 environments we have many possible combinations of properties - if we chose to introduce more type safety by creating a class hierarchy instead of passing in properties to a class constructor, we would have an explosion of class types that would be unmanageable.
We can increase the type safety of the current code. Some of our properties are just strings. The properties are to configure S3 buckets and we actually know that certain groupings of combinations of the properties define the types of bucket. We could go on to create a class hierarchy to correspond to the types of S3 bucket; there would only be 4 types of S3 bucket at the moment but this will likely grow over time....
An alternative approach to using class hierarchies in Typescript is to use types and object literals and these can be grouped together in what is known as the 'Companion Object Pattern'. A class in Typescript defines both types and values; both exist in separate namespaces. It is legal in Typescript to define both a type and an object literal with the same name. A single import will obtain both and Typescript will infer from the usage context whether you meant to use the type or the value. Using this approach, we can build component types with corresponding values and then assemble them into more complex types that correspond to domain entities (in our case that means the different types of S3 bucket). This will be demonstrated later on in this post.
First, here is an example of introducing more type safety to the properties that were defined above as 'strings':
We know there are a defined set of 'type1' bucket names, therefore we can create a 'type union' of their possible values:
type ROS_TYPE1_BUCKETNAME = 'ros-type1-dev' | 'ros-type1-test' | 'ros-type1-prod';
This is a type literal, it is a subtype of 'string' that can only have 1 of the above three discrete values or will cause a compiler error. It is also a type union - the result of a type union will have the properties of one of the types in the union.
(Why not use an Enum instead and create an enum called ROS_TYPE1_BUCKETNAME? Typescript can have problems with exported enums that are consts (they are inlined in the generated JS files). Enums that are not consts can use ordinal access (eg ROS_TYPE1_BUCKETNAME[0]) but there is no checking that the ordinal actually exists on the enum. The compiler will not catch the expression ROS_TYPE1_BUCKETNAME[5] even though it only has 3 members).
However, there is a problem with the type union literal approach. We want to create a factory function later on that will use the bucketame as its argument and from the type of the bucket name identify which type of bucket to build. We are going to have (for now) four types of bucket name and the factory function will have an argument whose type will be a union of all of those four types. How will the factory function know the actual type of the argument?
As stated above, we can now build a composite type of all legal names for all S3 buckets, called 'ROS_BUCKETNAME':
type ROS_BUCKETNAME = ROS_TYPE1_BUCKETNAME | ROS_TYPE2_BUCKETNAME | ROS_TYPE3_BUCKETNAME | ROS_TYPE4_BUCKETNAME;
We will define a factory function that takes an argument of this type and returns a value of type RoSBucketProps:
function createBucketProps(bucketName: ROS_BUCKETNAME): RoSBucketProps { .....
If you attempt to pass in a value that is not one of the string literals of ROS_BUCKETNAME, you will get a compiler error. However, typescript cannot actually determine the type of the argument at runtime, therefore it cannot decide which type of bucket to create in the factory function. A simple solution (but one which violates the principle of having DRY code) is just to test the value of bucket name and then do a type assertion with the idiom of:
if (bucketname === 'ros-type1-dev' || bucketname === 'ros-type1-test' || bucketname === ''ros-type1-prod'){
const type1bucketname: ROS_TYPE1_BUCKETNAME = (bucketname as ROS_TYPE1_BUCKETNAME);
.....etc
}
This is horrible as we have repeated all of the string literals we typed before and we have to maintain both lists. There is a better (if confusing solution). Since Typescript 3.4, the language has introduced const assertions, which are a way to declare a type as immutable and get the narrow literal type directly. I must confess I don't fully understand how this works, however the following code solved all of my requirements (don't forget the 'as const' at the end of the tuple or the types all degenerate to 'string'):
const ROS_TYPE1_BUCKETNAME = ['ros-type1-dev', 'ros-type1-test', 'ros-type1-prod'] as const;
type ROS_TYPE1_BUCKETNAME = typeof ROS_TYPE1_BUCKETNAME[number];
We will now be able to assert on the type of a bucket name using the idiom:
return ROS_TYPE1_BUCKETNAME.indexOf(bucketName as ROS_TYPE1_BUCKETNAME) > -1;
We will use this idiom in typeguard functions which we will create below.
The above matching type literal and object literal is an example of using the companion object pattern, we define a type literal and then a value having that type and give them the same name. Here is another example:
type KMS_ENCRYPTION = 'aws: kms';
const KMS_ENCRYPTION: KMS_ENCRYPTION = 'aws: kms';
We do the same again below but this time define a type for an object literal and then create an object literal of that type - again both have the same name:
type RosVersionedProps = {
readonly versioned: true
}
const RosVersionedProps: RosVersionedProps = {
versioned: true
}
By defining a type for the object literal we have been able to specify that it's versioned property is immutable. You cannot declare readonly on an object literal property inline. Defining this type is one way to give us an immutable object. One other way would be to use the const assertion idiom used previously to make immutable tuples:
const RosVersionedProps = {
versioned: true
} as const
As we know that KMS managed buckets in our domain will always use KMS encryption; we can now start to assemble composite type and corresponding companion objects:
type RosKMSManagedProps = {
readonly bucketEncryption: BucketEncryption.KMS_MANAGED, //value taken from an AWS enum
readonly putBucketEncryption: KMS_ENCRYPTION
}
const RosKMSManagedProps: RosKMSManagedProps = {
bucketEncryption: BucketEncryption.KMS_MANAGED,
putBucketEncryption: KMS_ENCRYPTION
}
And we can compose these discrete types into more complex types. The following type models buckets which are versioned and KMS Managed:
type RoSVersionedKMSManagedProps = RosVersionedProps & RosKMSManagedProps;
const RoSVersionedKMSManagedProps: RoSVersionedKMSManagedProps = { ...RosVersionedProps, ...RosKMSManagedProps };
The type above is a type intersection - the resulting type has all of the properties of the original types.
Now we can create a factory that will return our composed types, using the bucket name as its sole parameter. A naive implementation would type bucket name as a string but we have created our ROS_BUCKETNAME type:
function createBucketProps(bucketName: ROS_BUCKETNAME): RoSBucketProps {
if (isRosType1BucketName(bucketName)) {
const props: RoSType1Buckets = { ...RoSVersionedKMSManagedProps, ...{ bucketName: bucketName } };
return props;
} else if ...............
Note that the function returns a RoSBucketProps - this ensures compatibility with client code. Internally we check that the return value is the much more specific type - RoSType1Buckets. Typescript is satisfied because RoSType1Buckets and RoSBucketProps have the same shape.
What is the isRosType1BucketName() function? This is a typeguard function. By using it typescript is able to narrow the type of the bucket name arg to a ROS_TYPE1_BUCKETNAME. This is now the type of the bucket name arg inside the scope of that if-block with no need for any more type assertions. The implementation of the typeguard function is why we created the tuple consts above to define the types and values for ROS_TYPE1_BUCKETNAME:
function isRosType1BucketName(bucketName: ROS_BUCKETNAME): bucketName is ROS_TYPE1_BUCKETNAME {
return ROS_TYPE1_BUCKETNAME.indexOf(bucketName as ROS_TYPE1_BUCKETNAME) > -1;
}
Typeguard functions return a type assertion that must follow a convention of 'argname is type', hence the return type of bucketName is ROS_TYPE1_BUCKETNAME
.
The full factory function is this:
function createBucketProps(bucketName: ROS_BUCKETNAME): RoSBucketProps {
if (isRosType1BucketName(bucketName)) {
const props: RoSType1Buckets = { ...RoSVersionedKMSManagedProps, ...{ bucketName: bucketName } };
return props;
} else if (isRosType2BucketName(bucketName)) {
const props: RosType2Buckets = { ...RoSVersionedKMSManagedProps, ...{ bucketName: bucketName } };
return props;
} else if (isRosType3BucketName(bucketName)) {
const props: RosType3Buckets = { ...RoSUnVersionedAESManagedProps, ...{bucketName: bucketName} };
return props;
} else {
const props: RosType4Buckets = { ...RoSUnVersionedAESManagedProps, ...{ lifecycleRules: [] }, ...{ bucketName: bucketName} };
return props;
}
Typescript can figure out that because all of the other types were tested in the first 3 if else statements then the fourth must have the type ROS_TYPE4_BUCKETNAME - if this were not so then the bucket name type would still be ROS_BUCKETNAME and the code that uses the spread operator to generate a RosType4Buckets would not compile.
Top comments (0)