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

Inheritance for User-Defined Types #14135

Open
johnlokerse opened this issue May 21, 2024 · 8 comments
Open

Inheritance for User-Defined Types #14135

johnlokerse opened this issue May 21, 2024 · 8 comments
Assignees
Labels
discussion This is a discussion issue and not a change proposal. enhancement New feature or request
Milestone

Comments

@johnlokerse
Copy link
Contributor

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

This feature request is not related to a problem I have, but more like an improvement on User-Defined Types (UDT). I am looking at UDTs like a class in TypeScript or C# for example. I know it's a different concept in Bicep, but it looks similar.

What would be great is to have the same concept of class inheritance in User-Defined Types. So, to have some kind of base type where other UDTs can extend or inherit from that base type.

Describe the solution you'd like
A clear and concise description of what you want to happen.

I will provide a practical example. In my recent PR in the Azure Verified Modules (Azure/bicep-registry-modules#1931) repository I tried to achieve some kind of "inheritance" this way:

type zoneBaseType = {
  name: string
  metadata: object?
  ttl: int?
  roleAssignments: roleAssignmentType
}

In the zoneBaseType UDT the base properties are defined that are used in every record type (a, aaaa, cname etc.) as seen below:

type aType = {
  @description('Required. The base properties of the record.')
  base: zoneBaseType

  targetResourceId: string?

  aRecords: {
    ipv4Address: string
  }[]?
}[]?

The outcome is what I wanted, but not quite what I had in mind.

What would be great functionality to have is inheritance like we see in C# and TypeScript, so we can remove the need for a property like the base: zoneBaseType property.

How I see it with inheritance:

type baseType = {
  name: string
  metadata: object?
  ttl: int?
  roleAssignments: roleAssignmentType
}

type aType extends baseType = {
  targetResourceId: string?
  aRecords: {
    ipv4Address: string
  }[]
}[]?

The type aType now extends from baseType, so when using the UDT aType you make use of the following properties:

  • [inherited / extended from baseType] name: string
  • [inherited / extended from baseType] metadata : object?
  • [inherited / extended from baseType] ttl: int?
  • [inherited / extended from baseType] roleAssignment: roleAssignmentType
  • targetResourceId: string?
  • aRecords: object

I hope this is clear and understandable. If not, please let me know.

@cedricbraekevelt
Copy link

Have also been waiting for this to improve "dirty" UDT's!

@johnlokerse
Copy link
Contributor Author

johnlokerse commented May 30, 2024

@alex-frankel @stephaniezyen I forgot to ask about it in the Q&A on the community call. What do you guys think about user-defined types inheritance?

@alex-frankel
Copy link
Collaborator

seems like a great addition, but also curious to hear what @stephaniezyen and @jeskew think!

@jeskew
Copy link
Contributor

jeskew commented May 30, 2024

I think Bicep definitely needs a mechanism for type reuse and composition, but I'm not sure inheritance is the right way to do it. Tagging this for team discussion: some alternatives we can look at would be using intersection (&) and union (|) operators like TypeScript or using the ... operator like TypeSpec and GraphQL.

Some background

My concern is that the Bicep/ARM object model (largely inherited from JSON schema) would allow for declaring child types where an input could validate against the child type but is not guaranteed to validate against the parent. Most object models that are designed to support hierarchies explicitly disallow categories of declarations that are permitted under JSON schema.

The best example of this in Bicep's object model is how you would declare a dictionary/map. The Bicep equivalent of Dictionary<string, string> would be:

type dict = {
  *: string
}

The equivalent ARM JSON would be as follows, and the same thing in JSON Schema would be very similar:

{
  ...
  "definitions": {
    "dict": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      }
    }
  }
}

The trouble is introduced by the fact that named properties don't have to validate against the "additionalProperties" schema. The following would be legal in ARM and JSON Schema:

{
  "type": "object",
  "properties": {
    "id": {
      "type": "int"
    }
  },
  "additionalProperties": {
    "type": "string"
  }
}

and can be expressed in Bicep as:

type aType = {
  id: int
  *: string
}

JSON Schema allows you to declare the same schema using their type composition mechanism ("allOf"):

{
  "allOf": [
    {
      "type": "object",
      "properties": {
        "id": {
          "type": "int"
        }
      }
    },
    {
      "type": "object",
      "unevaluatedProperties": {
        "type": "string"
      }
    }
  ]
}

You can have data that validates against this combined schema ({id: 2, another_property: 'a string'}) that would not validate against the second schema included in "allOf". (JSON Schema had to introduce unevaluatedProperties in the draft-2019-09 version because combining additionalProperties and allOf has too many footguns.)

This situation is OK under the structural type system used by Bicep, ARM, and JSON Schema but would not be under the hierarchical type system users would recognize from any mainstream object-oriented language. Adding extends to Bicep would therefore create an impedance mismatch and some really awkward semantics:

type parent = {
  *: string
}

type child extends parent = {
  id: int
}

var data = {
  id: 2
  property: 'value'
}

output works child = data          // <-- Works! data is a child
output does_not_work parent = data // <-- Throws error! data is not a parent

Option 1: Use intersection (&) and union (|) operators to compose types

Template authors could use & and | in Bicep (and "allOf" and "anyOf" in ARM) to express new types as intersections or unions of existing types.

Example

type foo = {
  foo: string
}

type bar = {
  bar: string
}

// the following are all equivalent to each other
type foobar = foo & bar
type foobar = foo & { bar: string }
type foobar = { foo: string } & bar
type foobar = { foo: string } & { bar: string }
type foobar = {
  foo: string
  bar: string
}

Prior art

The & and | are borrowed directly from TypeScript, where they have the same meaning. "allOf" and "anyOf" are taken directly from JSON schema.

Pros

  • The syntax will be familiar to TypeScript users.
  • The Bicep and ARM syntaxes would have a 1:1 correspondence, so there would be no information lost when compiling and decompiling templates.
  • We have been asked about supporting primitive unions in the past (specifically string | int), which this syntax would allow for. This would also give users a way to declare an any type (type any = (string | int | bool | array | object)?).

Cons

  • It's easy to create "impossible" types (string & int)
  • The problem JSON Schema ran into with additionalProperties that necessitated unevaluatedProperties would come up, and we would need to solve that in a way that makes sense for both ARM and Bicep.

Option 2: Use shallow merge (...) operator to apply the properties of a type to another object type

The ... operator could be allowed in object type declarations, where it would mean that the referenced object type's properties should be shallow-merged into the target object type definition. Decorators on the referenced object type would not be merged in.

Example

type foo = {
  foo: string
}

type bar = {
  bar: string
}

type foobar = { // <-- equivalent to { foo: string, bar: string }
  ...foo
  ...bar
}

type withOverride = { // <-- equivalent to { bar: string, foo: int }
  ...foobar
  foo: int
}

type withAdditionalProps = { // <-- equivalent to { foo: string, bar: string, *: int }
  ...foobar,
  *: int
}

type withOverriddenAdditionalProps = { // <-- equivalent to { foo: string, bar: string, *: bool }
  ...withAdditionalProps
  *: bool
}

@secure() 
type secureFoobar = { // <-- equivalent to @secure() { foo: string, bar: string }
  ...foobar
}

type nonSensitive = { // <-- equivalent to { foo: string, bar: string }
  ...secureFoobar
}

Prior art

GraphQL fragment inclusion uses a similar syntax for composition. This type syntax has the same meaning as Bicep's existing ... operator (albeit in a new context).

This option is similar to embedding in Go in its effect, though Go does not use any operator to embed.

This option is also one of TypeSpec's model composition syntaxes. I should note that TypeSpec has both ... composition and inheritance, with different semantic meanings. Bicep's ... would exactly match the semantics of TypeSpec's ... (copy properties but not decorators).

Pros

  • Unlike &/|, the ... operator wouldn't be able to create unfulfillable type contracts.
  • No changes required in ARM engine.
  • ... does not connote any form of "is-a" relationship between the property source and the spread target.

Cons

  • ... would almost certainly need to be evaluated and handled at compile time, meaning compiling and then decompiling a template could be a lossy operation.
  • This is a slightly more exotic option for users with a background writing code in C# or JS.

@jeskew jeskew added the discussion This is a discussion issue and not a change proposal. label May 30, 2024
@slavizh
Copy link
Contributor

slavizh commented May 31, 2024

+1 on this. It becomes very problematic with discriminator() types. Sometimes the types differ by only 1-2 parameters and if you have 10 types with 10 parameters and 7 of them the same you need to write the same parameters and descriptions 70 times. In case of changes you need to use replace in vscode and be careful that everything is replaced.

@johnlokerse
Copy link
Contributor Author

johnlokerse commented Jun 4, 2024

Thanks @jeskew. Had to read into it and I realise there is a lot more to learn on the ARM backend topic 😉. Inheritance doesn't have to be the way to go, it's something I knew from my developer "past".

Both options 1 and 2 do what I was aiming for in my inheritance example, but I would vouch for the intersection solution because this is functional wise easy to understand.

A large part of the Bicep users do not have a development background (at least the people I work with), so spread can be harder to understand. Also, it looks like option 1 is technically "easier" to implement than option 2?

@mennolaan
Copy link

I second this. We are trying to implement UDT as a mean of like c# Interfaces. Aka strong typing complex object for your parameters. This will increase the overall resilience of our bicep modules and patterns. The roleAssignmentType is a great example. For every module I would like to have a type that inherits from roleAssignmentType and per module i want to add allowed values for the roleTypeNames. This isn't possible either unfortunately with UDT.

Someday i hope the bicep team would look more into c# and see what powerful mechanisms this would offer. You don't have to reinvent the wheel. Its already been done, for decades with c#. Embrace it, it has been build by your colleagues after all.

@mennolaan
Copy link

Summer is done, checking up on this feature.

If we look at MS AVM we see that types are copied everywhere, wich I don't understand, but thats another discussion.

I really hope inheritance of user defined types is high on prioty as this will really make bicep more modular and type safe.

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
Status: Todo
Development

No branches or pull requests

7 participants