Azure / bicep

Bicep is a declarative language for describing and deploying Azure resources
MIT License
3.23k stars 748 forks source link

Lambda as a top-level data type #15387

Open gaorlov opened 3 days ago

gaorlov commented 3 days ago

Is your feature request related to a problem? Please describe.

I maintain a set of modules that export a set of types that overlap in their interfaces, both in terms of data structure and functionality. While I can declare the type data structures in a way that lets me use "duck typing" to unify them:

// interface.bicep
type MyInterface = {
  sharedField: string
}

// a.bicep
type A = {
  sharedField: string
  aField: bool
}

// b.bicep
type B = {
  sharedField: string
  bField: object
}

Which allows me to pass either an A or a B into a method that expects an Interface. And if i'm missing a field it expects, i will get errors letting me know that i'm passing in an incompatible object.

func interfaceCall(interface MyInterface) any => doStuff(interface.sharedField)

var a = {
  sharedField: 'a'
  otherField: false
}

// success
interfaceCall(a)

// build errors
interfaceCall({})

However, if I want Interface to define not just the properties, but also member methods, there's really not a way to do this today. There's no way to express that the interface expects a method to be present. Best I can do is to export the same function name in all my interface-bound modules.

// a.bicep
type A = { /* same as above*/ }

@export()
func interfaceCall(interface MyInterface) any => // A-specific operation

// b.bicep
@export()
type B = { /* same as above*/ }

@export()
func interfaceCall(b B) any => // B-specific operation

But now, I have to know what import brought in what functions in order to call them

// main.bicep
import * as A from './a.bicep'
import * as B from './b.bicep'

param MyInterface x
param B b

x.interfaceCall(x) // syntax error

A.interfaceCall(x) // maybe correct? I don't actually know what x is
B.interfaceCall(b) // correct

Describe the solution you'd like

I would love to hear what the thoughts are on adding lambdas to the top-level data type set, since they are already present in the built-in functions like map.

Using this proposal I can express the examples above as

// interface.bicep
@export()
type MethodType = lambda(MyInterface) any

@export()
type Type = {
  sharedField: sharedFieldType
  method: MethodType 
}

// a.bicep
import * as Interface from './interface.bicep'

type A = {
  sharedField: string
  method: Interface.MethodType 
  aField: bool
}

func interfaceCall(a A) any => // A-specific operation

@export()
func new(o object) A => union({method: interfaceCall}, o)

// b.bicep
import * as Interface from './interface.bicep'

type B = {
  sharedField: string
  method: Interface.MethodType 
  bField: object
}

func interfaceCall(b B) any => // B-specific operation

@export()
func new(o object) B => union({method: interfaceCall}, o)

// main.bicep
import * as A from './a.bicep'
import * as B from './b.bicep'

module _ './my-module.bicep' = {
  name: '_'
  params:{
    interface: A.new( {
      sharedField: 'a'
      aField: true
    })
  }
}

// my-module.bicep
import * as Interface from './interface.bicep'

param interface Interface

// doesn't have to know what concrete type the object is
var result = interface.method(interface)
jeskew commented 15 hours ago

@gaorlov Is the motivation for this request a need to pass behavior into a module?

I can understand how that would make certain deployment scenarios easier, but it would be difficult to make this work with ARM's underlying execution model. Every resource and module statement is essentially a remote procedure call, and only data that can be represented as JSON is permitted to cross those boundaries today.

gaorlov commented 9 hours ago

@jeskew that is totally fair. I want to be clear that i understand that it would be unreasonable for me to expect arm to support full closures and try to use contunuations or any other lisp-y wizardry. My hope is to have an interface that can be implemented by multiple types and have the ability to be executed without forcing the caller to explicitly call the underlying module.

In terms of limitations, help me understand the constraints better. I saw that map and other functions can take a lambda object that is expressible in json. Can those objects be made available outside the built in calls? Like how functions are defined, but assignable to a variable.