Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom type imports #9311

Closed
WhitWaldo opened this issue Dec 16, 2022 · 11 comments
Closed

Custom type imports #9311

WhitWaldo opened this issue Dec 16, 2022 · 11 comments
Labels
discussion This is a discussion issue and not a change proposal. enhancement New feature or request

Comments

@WhitWaldo
Copy link

WhitWaldo commented Dec 16, 2022

This is another in my series of threads about custom types. I can't find an original issue from which the custom types where originally imagined, so pardon the new threads on it, but I thought separate threads for each idea made more sense than one giant one.

Import Custom Types

In this first iteration of custom types, they're each defined in modules and provide a more rigid structure to the input parameters in a way that's consumable by the callers of these modulues. But I think this leaves ample opportunity on the table to better organize the data in larger "orchestration" modules upstream. Today, the root module remains a mess of raw data inputs that are just waiting to be passed into the structured types of the referenced modules.

I'd like to see greater reusability of types (like interfaces in C# or Typescript) and would thus to see the idea of namespaced types enter the larger picture. Bicep today treats separate files as whole modules and I'd like to build on that by allowing a wholly separate file to contain a collection of types. From a development perspective then, I'd like to see the inclusion of an @import-types './types.bicep' (naming is hard) statement at/near the top the file/module that can reference other modules and include their types from an intellisense and incorporation standpoint.

Keep the approach today for those types that are module-specific as they don't need to be imported anywhere else. But this would allow for greater type re-use in a manner that also allows type usage outside of strictly from a parameter-validation scenario, e.g.:

//types.bicep

@minLength(1) //Indicating that the individual element should have a min string length of 1
type nodeType = string

@minLength(1) //Indicating that the array itself should have a min length of 1
type nodeTypes = nodeType[]

type certificateAssignment = {
  name: string
  targets: nodeTypes
}
//main.bicep
@import-types './types.bicep'

type deployment = {
  name: string
  assignments: certificateAssignment[]
}

param Deployment deployment = {
  name: 'MyDeployment'
  assignments: [
    name: 'example.com'
    targets: [
      'frontend'
      'backend'
    ]
  ]
}

Especially because the custom types themselves are bundled in with the deployment, the import of the types should not mean that all types are included in the modules importing them, but rather only those that are explicitly or implicitly referenced should be included in the output build.

Import Anything

Extending this idea a bit from just types, on a number of community calls I've heard asks for globalized values that can be referenced collectively across the deployment. I'd like to submit a variation of this as my own preferrerd solution to that ask. A true global namespace (like that in Javascript on the window) can be an unwieldy mess of variables (limiting the utility of Intellisense to narrow the intent) and really muddy the waters about what a (non-constant) variable is set to, especially given order-of-operations concerns.

Rather, I'd ask for a variation of the ask above - change the keyword from @import-types to @import and allow both types and variables to be passed as immutable constants back to the consuming modules. Locally, they can be manipulated and derivatives created as needed, but without being able to write back to the module they're originally defined in. Like the types, if the variables in the module aren't referenced, don't include them in the output build.

//types.bicep

@minLength(1) //Indicating that the individual element should have a min string length of 1
type nodeType = string

@minLength(1) //Indicating that the array itself should have a min length of 1
type nodeTypes = nodeType[]

type certificateAssignment = {
  name: string
  targets: nodeTypes
}

var assignments certificateAssignment[] = [
  {
    name: 'example.com'
    targets: [
      'frontend'
      'backend'
    ]
  }
  {
    name: 'another.com'
    targets: [
      'frontend'
    ]
  }
]

Because this module references the assignments variable from types.bicep, all types and variables from types.bicep would be included in this module at build.

// main.bicep

@import './types.bicep'

module myModule './whatever.bicep'  = {
  name: 'Test'
  parameters: {
    targetAssignments: assignments //included from types.bicep
  }
}

This module only uses two of the types (explicitly uses nodeTypes and implicitly uses nodeType) so it would exclude the other type and variable from the output module.

// nodeTypes.bicep

@import './types.bicep'

param nodeTypeNames nodeTypes = [ //Uses imported nodeTypes type definition
  'frontend'
  'backend'
]

module nodeTypeDeployment './myNodeTypeDeployment.bicep' = [for nodeTypeName in nodeTypeNames: {
  name: 'node-type-${nodeTypeName}'
  parameters: {
    name: nodeTypeName
  }
}]

Import select types

Addressing some of the comments on this thread, it'd be great to be able to selectively import only some types. Just like the example immediately above this, it should emit identical output because both the nodeType (implicitly included via its explicit reference in the original type definition) and nodeTypes type definition should be included in this outputted module as though it was defined here to begin with (assuming the same runtime as used today for custom types, but allowing room for optimization down the road), but the other unused types (certificateAssignment) would be excluded because they're not explicitly imported. The earlier example excluded the remaining unused types implicitly because they were imported, but simply not used.

// nodeTypes.bicep
@import { nodeTypes } from './types.bicep'

param nodeTypeNames nodeTypes = [ //Uses imported nodeTypes type definition
  'frontend'
  'backend'
]

module nodeTypeDeployment './myNodeTypeDeployment.bicep' = [for nodeTypeName in nodeTypeNames: {
  name: 'node-type-${nodeTypeName}'
  parameters: {
    name: nodeTypeName
  }
}]

Type aliases

Addressing the concern in #9639 around the symbolic names, it would be great to support aliases on the imported types to prevent in-module name collisions.

Repurposing the same example above, I'd like to offer the as keyword to denote the symbol to use for the type as an alias in place of its original symbol. When producing the output module (again, assuming today's ARM runtime approach for custom types wherein the type is bundled with every module), the alias should be used as the type's name instead of its original name so as to prevent any naming collisions. For example:

// nodeTypes.bicep
@import { nodeTypes as manyNodes } from './types.bicep'

param nodeTypeNames manyNodes = [ //Uses imported nodeTypes type definition, but aliased as 'manyNodes'
  'frontend'
  'backend'
]

module nodeTypeDeployment './myNodeTypeDeployment.bicep' = [for nodeTypeName in nodeTypeNames: {
  name: 'node-type-${nodeTypeName}'
  parameters: {
    name: nodeTypeName
  }
}]

When built, this should place the type definition inline using the alias such that it would be no different than having originally written (except that the former is easier on developers since it allows reusability and what follows is representative of the experience today):

// nodeTypes.g.bicep
@minLength(1)
type nodeType = string

@minLength(1)
type manyNodes = nodeType[] //Note that it uses the alias provided instead of the original symbol

param nodeTypeNames manyNodes = [
  'frontend'
  'backend'
]

module nodeTypeDeployment './myNodeTypeDeployment.bicep' = [for nodeTypeName in nodeTypeNames: {
  name: 'node-type-${nodeTypeName}'
  parameters: {
    name: nodeTypeName
  }
}]

These suggestions would greatly simplify the idea of using a deployment for a wide variety of implementation as these types and values could simply be implemented in project-wide and more narrowly-applicable modules can also be built as needed.

For locally-referenced imports, I can't immediately think of why the importation would be necessary to maintain in the build output and would instead recommend this be syntactical sugar for the repetition throughout the deployment. In other words, in the output ARM template, the variable and types should be referenced as though they were defined in the module and referencing modules themselves instead of imported.

Import Anything from Anywhere

Especially with the notion of public registries, I'd like to further see an evolution of the idea that allows for importing types and variables from versioned registry-based modules. I haven't worked much with the public registries myself, so I defer to the team as to whether it make sense to maintain a link to the registry for imports or again just use this import statement as a shortcut at build-time to copy all the utilized variables and types in the resulting module.

Change log:
2/13: Added suggestion to import specific types instead of a blanket import as well as aliases to imported symbols

Thank you for considering my request.

@WhitWaldo WhitWaldo added the enhancement New feature or request label Dec 16, 2022
@ghost ghost added the Needs: Triage 🔍 label Dec 16, 2022
@alex-frankel
Copy link
Collaborator

Related to #9357

@Chris-Mingay
Copy link

Chris-Mingay commented Dec 23, 2022

I'd be very interested in something like this as well. I've just this morning looked into userDefinedTypes and it currently isn't really an option for our highly modular bicep template.

I'm not sure of the inner workings of Bicep but I'm wondering if a typescript export type style approach would be possible, allowing multiple types to exist in the same definition files and also allowing modules to pick and choose which items are relevant.

// types/Animals.bicep

type Cat = {
  name: string
  furStyle: string
  age: int
}

type  Elephant = {
  name: string
  trunkLength: int
  toeNailsPainted: bool
}

export { Cat, Elephant }
// deployCats.bicep

@import { Cat } from './types/Animals';

param cats: Cat[]
// deployElephants.bicep

@import { Elephant } from './types/Animals';

param elephants: Elephant[]

@stephaniezyen stephaniezyen added discussion This is a discussion issue and not a change proposal. Needs: Author Feedback Awaiting feedback from the author of the issue labels Jan 4, 2023
@stephaniezyen stephaniezyen added this to the Committed Backlog milestone Jan 4, 2023
@dazinator
Copy link

dazinator commented Jan 5, 2023

Yep gave custom types a whirl today, and hit the need for this immediately.
For now have resorted to copying custom types around into the various templates that need them.

We have modules that build on top of other modules, and as such sometimes those higher level modules want to naturally reference the types exposed by the lower level ones - however they can't define a custom type that leverages those lower level custom types. For example:

  • vm.bicep
  • vm-cluster.bicep
  • main.bicep

Here, the logic to deploy a single vm is in vm.bicep and it exposes some custom types that are pretty primitive for a VM for example the Image spec.
The vm-cluster.bicep references vm.bicep but handles deploying multiple vm's as a cluster. It has options about how many to vm's to deploy etc. However it still also needs to take Image spec so it can pass that through to vm.bicep. At the moment I have to duplicate this type in both templates.

@pelle-domela
Copy link

I really like this import anything idea, though variables might not be compile-time constants so in my mind it doesn't make sense to import them. Maybe a new const keyword could be added for compile-time constants?

Right now a .bicep file itself defines a module: a set of resources with dependencies, inputs and outputs which can be deployed to Azure. Defining a type to use internally in a module makes sense, but importing types from other modules like this seems like it would be mixing two worlds.

I would instead suggest fundamentally changing what a .bicep file does, from defining a single module to defining multiple things: modules, types and perhaps constants and functions which can be exported and imported in other files. This would of course require a way to define which module is the "root" of the deployment. It could be the one called main, it could be exported with a special keyword, or it could be specified on the command line when compiling. Taking inspiration from Go, one could call what a .bicep file defines this way a package, and perhaps even allow defining a single package across multiple files.

This could for example look something like this:

// landingZoneSubscription.bicep

include { Budget } from 'budget.bicep'

// define a type
type LzSubscriptionDefintion = {
  applicationName: string
  subscriptionName: string
  addressPrefix: string
  budget: Budget
}

// define a module
def LandingZoneSubscription tenant = {
  // inside these brackes would basically be a copy-paste of an old-style .bicep file
  @description('The Landing Zone Subscription\'s definition')
  param defintion LzSubscriptionDefinition

  resource subscriptionAlias 'Microsoft.Subscription/aliases@2021-10-01' = {
    name: definition.subscriptionName
  }

  // more resources here

  output subscriptionId string = subscriptionAlias.properties.subscriptionId
}

export { LzSubscriptionDefinition, LandingZoneSubscription }
// landingZones.bicep

include { LzSubscriptionDefinition as LzDef, LandingZoneSubscription as LzSub } from 'landingZoneSubscription.bicep'

def LandingZones tenant = {
  @description('An array of definitions of all the Landing Zones to create')
  param landingZoneDefinitions LzDef[]

  module landingZones LzSub = [for lzDef in landingZoneDefinitions: {
    name: `deploy-lz-${lzDef.subscriptionName}`
    params: {
      definition: lzDef
    }
  }]
}

A backwards-compatible way would instead be C/C++-style header files that can only define types, constants and functions, which could then be imported by multiple .bicep files. The example above would then be:

// landingZoneSubscription.biceph

include { Budget } from 'budget.biceph'

// define a type
type LzSubscriptionDefintion = {
  applicationName: string
  subscriptionName: string
  addressPrefix: string
  budget: Budget
}
// landingZoneSubscription.bicep

targetScope = 'tenant'

include { LzSubscriptionDefintion } from 'landingZoneSubscription.biceph'

// inside these brackes would basically be a copy-paste of an old-style .bicep file
@description('The Landing Zone Subscription\'s definition')
param defintion LzSubscriptionDefinition

resource subscriptionAlias 'Microsoft.Subscription/aliases@2021-10-01' = {
  name: definition.subscriptionName
}

// more resources here

output subscriptionId string = subscriptionAlias.properties.subscriptionId
// landingZones.bicep

targetScope = 'tenant'

include { LzSubscriptionDefinition as LzDef } from 'landingZoneSubscription.biceph'

@description('An array of definitions of all the Landing Zones to create')
param landingZoneDefinitions LzDef[]

module landingZones LzSub = [for lzDef in landingZoneDefinitions: {
  name: `deploy-lz-${lzDef.subscriptionName}`
  params: {
    definition: lzDef
  }
}]

@jeskew
Copy link
Contributor

jeskew commented Mar 9, 2023

@pelle-domela I like the use of the include keyword instead of import, as to me that more accurately conveys how this feature would work under the hood. Type definitions would be copied into the compiled JSON template, so the mechanism would be much closer in spirit to C's #include than it would be to JavaScript's import. Since import has already been used for extensibility providers (#3565), I think it would be confusing if import './path/to/template.bicep' did something completely different from import <extensibility provider name>.

Besides types and compile-time resolvable variables, one other construct that might be useful to share via include would be user-defined functions (#9239). /cc @SimonWahlin

@SimonWahlin
Copy link
Collaborator

I absolutely agree with @jeskew on import vs include.

I also think having an include feature would be helpful for many things and it's absolutely something that would come in handy for user defined functions.

@WhitWaldo
Copy link
Author

I agree - I haven't done anything with the extensibility providers, so I hadn't realized that was already a reserved keyword.

@jeskew
Copy link
Contributor

jeskew commented Mar 15, 2023

Because we're looking at using sharing functions, variables, and types at compile time, I opened the include proposal as a new issue: #10121

@ghost
Copy link

ghost commented May 19, 2023

Hi WhitWaldo, this issue has been marked as stale because it was labeled as requiring author feedback but has not had any activity for 4 days. It will be closed if no further activity occurs within 3 days of this comment. Thanks for contributing to bicep! 😄 🦾

@WhitWaldo
Copy link
Author

Well, in that case, bump!

@alex-frankel
Copy link
Collaborator

The ability to import types was released in v0.21

This issue was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion This is a discussion issue and not a change proposal. enhancement New feature or request
Projects
Archived in project
Development

No branches or pull requests

8 participants