Question Details

No question body available.

Tags

api-design extensibility

Answers (8)

Accepted Answer Available
Accepted Answer
June 25, 2025 Score: 1 Rep: 2,769 Quality: High Completeness: 80%

I'm going to post a second answer, which is to suggest that the consumer can opt-in to a list of the possible values it expects to exist.

Here's how you would do it in TypeScript:

type AllPossibleValues = "visa" | "mastercard" | "amex"; 

type CreditCard = { scheme: TSchema; }

function getCard(allowedSchemes: Array): CreditCard {

if(allowedSchemes.includes("visa" as TSchema)){ return { // @ts-expect-error - not sure if there's a better way to do this, but we've already established that visa is one of the types scheme: "visa" } }

throw new Error("ends possiblities")

}

// From the consumers point of view: const result = getCard(["visa", "mastercard"]); result.scheme // ^? "visa" | "mastercard"

const result2 = getCard(["visa", "mastercard", "amex"]); result2.scheme; // ^? "visa" | "mastercard" | "amex"

// But consumers can't opt into schemes that do not exist. getCard(["visa", "garbage"])

TSPlayground

Unfortunately, I'm not sure how you would do the equivalent with either OpenAPI or GraphQL.

For OpenAPI, the hierarchy that determines the shape of a response is

path > method > response code > content type.

ie. you can't determine response shape based on request parameters.

So the best way I can think you could do it is by the via Accept header and content-type, as suggested in my other answer.

For graphql, I think you're mostly out of luck, except to say that you can simply declare new endpoints, which will be non breaking to the users of the existing endpoints.

June 19, 2025 Score: 6 Rep: 79,059 Quality: Medium Completeness: 20%

As long as you have code that shows a different behavior for each of the types in the type union, changing the type union will affect the dependent code.

If you have to call a different 3rd-party API for each credit card type (e.g. different payment processors), then there is no way around it but updating that code when you start accepting a new brand of credit cards.

In fact, it is not the fact that you add a new type to the type union that causes the cascading changes. The cause of those changes is the change in requirements to support a new credit card brand.

Those changes would be equally needed if you encoded your credit card type as a string and encoding it as a type union actually gives you the extra safety net that the build tooling can assist you in finding places where you missed a knock-on effect of the new requirement.

June 20, 2025 Score: 2 Rep: 3,954 Quality: Low Completeness: 80%

To get good auto-complete for the card types you mention, while not claiming your list is exhaustive, add string & {} to the union instead of just string:

type CreditCard = {
    scheme:  "Visa" | "Mastercard" | (string & {}); 
};

See https://stackoverflow.com/a/67767306/2526181 and a test based on your code at the TS playground.

If you change 'Mastercard' in the function call to '' and invoke autocomplete inside the empty string with ctrl-space you should only be offered Visa and Mastercard. But you can manually type any string without getting a type error.

June 20, 2025 Score: 1 Rep: 59,581 Quality: Medium Completeness: 60%

There is no one-size-fits-all solution to how you want an existing consumer to behave when a new option gets added.

If anything, you want them to not respond to it until they've had a chance to learn about this new option and think through what response they would like to provide/do/respond with in that case. Until such time, it seems safest for their code to only respond to the values that their code was designed to respond to.

As a consumer, I tend to not write my business logic tied to an external party's enum (or similar closed list of options) anyway, I map it to a domain enum and write my code against that.
Generally speaking, I will already add an Unknown value to the domain enum, so that my domain/application logic can explicitly declare what it wants to do when it encounters an Unknown. I also tend to raise a warning message from the mapping itself when an Unknown is encountered, so that I can log the incoming value that was sent for troubleshooting purposes.

In C# there is also the side benefit of assigning Unknown the default enum value (0 for integers) so you don't run the risk of forgetting to set an enum and it looking exactly like a "real" value.


Note: the above answer mostly refers to enums but this applies equally to data types with a closed list of expected options - I simply declare one of the possible values as a catch-all for things my application did not account for.

June 19, 2025 Score: 1 Rep: 6,407 Quality: Low Completeness: 60%

If your API produces a new value that the user needs to consume, then you have you have to accept the breaking change. The user has to know how to process "amex", and using a string does not really help with that. Preemptively listing card types might help, but there is a risk of users relying on a specific implementation instead of the contract, and that is also a source of bugs.

If your API both produces and consumes the new value you will not have a breaking change. I.e. If your API also includes the processCard function and everything else that needs the credit card type. Similarly, if your user produces values your API consumes it would not be a breaking change. Your user would still need to do updates to support the new card types, but it would still work with existing cards.

It might also be possible to specify to the API what card types are allowed, so that unexpected card types are never returned. Or have some sort of versioning system of the API that ensures a consistent behavior. You could also specify in the contract that new card types might be added and how the user should deal with these. Likely by invoking some kind of fallback or error handling system.

June 19, 2025 Score: 0 Rep: 109 Quality: Low Completeness: 80%

If we're talking about APIs in terms of web interfaces or a similar context where we can only pass data, not behaviour, back to the caller, I think Bart's answer is the best you can do.

However, if we're talking about APIs between different objects and functions in the same program, where we can return actions along with behaviours, this is (if I understand the question) ultimately the problem polymorphism is solving, and it dose so by tying the behaviour to the value that decides whether to choose that behaviour. (You could do what I'm about to suggest over the web, but it would involve the client executing server-provided code dynamically, so unless you will always control both ends this usually gets too convoluted and/or insecure to be worth it)

This amounts to a strategy where, instead of having the function look like this:

function processCard(card: CreditCard, context: Any): number {
    switch(card.scheme) {
       case "Visa": return 1; 
       case "Mastercard": return 2; 
    }
}

you instead have something that looks like:

function processCard(card: CreditCard, context: Any): number {
    return card.dothething(context)
}

and then when you build the CreditCard value in the first place, you have something resembling:

type CreditCard {
    scheme: string
    dothething(context: Any): number 
}
function processvisa(context: Any): number {
    return 1; 
}
function processmastercard(context: Any): number {
    return 2; 
}
function creditcard(scheme: string) -> CreditCard {
    var thingdoer;
    switch(scheme) {
       case "Visa": thingdoer = processvisa; break; 
       case "Mastercard": thingdoer = processmastercard; break; 
    }
    return CreditCard { 
        scheme: scheme, 
        dothething: thingdoer; 
    }
}

(I don't use Typescript very much, so excuse any errors in the above, but I hope the logic is clear)

As an aside, it's worth mentioning that this overall strategy is applicable to any language, but the exact options you have for implementing it vary depending on how statically and strongly your typing is, and each option may have performance tradeoffs. Java or C++ will straight up not let you do the above where I cram the different methods onto the one type, you need to use subclasses. Conversely, at least one language (Scheme?) implements if as a method on bool rather than a language primitive because you can pass arbitrary blocks of code around easily.

The above structure means that so long all the callers don't ever "open the lid" on what scheme the card is, only ever delegate decisions that might depend on that to the CreditCard object, there is only one place that needs to update when a new scheme is introduced and all clients of that place will keep working. (You can see how to introduce processamex just from the example.) I say "open the lid" because reading the value isn't inherently problematic, so long as the value retrieved is treated as opaque.

Depending on the control flow, you might expect this to mean the CreditCard type becomes entangled with lots of other unrelated logic because, e.g. what processor API endpoint you need to access will vary with the scheme. You can avoid this by keeping in mind that the processing and return value from the method on CreditCard has to cover only what varies with different schemes - you don't want the method doing a whole HTTP request to the endpoint if the bit that actually changes is only the URL. Besides that, the contracts of these decision points work the same way as any other function - so long as new schemes can implement them while staying inside the postconditions that the clients are expecting, everything is fine.

You can also use this as a launchpad for patterns like strategy objects as the number of scheme-dependent decisions grows so that these are separated from the value object itself while still being isolated from callers. But this leads into the main disadvantage here - the switch-case plan you started with effectively creates one "incompatibility point" for every location that's depending on the scheme value in the event you want to add a new scheme. Making these decisions polymorphic on the card object itself instead means that the event of needing a new decision point creates incompatibilities proportional to the number of card schemes you have. Given there are only a few credit card providers and you probably have lots of decisions and data associated with each, that's probably a tradeoff you want to make, but it is a tradeoff, and may not be worth it for other cases where you might want to do this.

tl;dr - you can't extend the contract of an enum, in a vacuum, but promoting it into a "rich" object with its own attached logic methods, lets you manipulate the contracts on those methods as with other functions.

June 20, 2025 Score: 0 Rep: 2,769 Quality: Low Completeness: 80%

As other answers mention, this simply is a breaking change.

Here's how you could allow consumers to consume this new version, in a non-disruptive manner.

TypeScript

Say your initial data fetching function was this:

type CreditCard = {
    scheme: "Visa" | "Mastercard"
}

function getCreditCard() : CreditCard { return { scheme: "Visa" as const }

}

function processCard(card: CreditCard): number { // not implemented }

const card = getCreditCard(); processCard(card);

You can make use of function overloading to allow it to return the new credit card type:

type CreditCard = {
    scheme: "Visa" | "Mastercard"
}

+type CreditCardV2 = { + scheme: "Visa" | "Mastercard" | "Amex" +}

+function getCreditCard(apiVersion: "v2") : CreditCardV2 function getCreditCard() : CreditCard +function getCreditCard(apiVersion?: "v2") : CreditCard | CreditCardV2 {

+ if(apiVersion === "v2"){ + return { + scheme: "Amex" as const + } + }

return { scheme: "Visa" as const }

}

function processCard(card: CreditCard): number { // not implemented }

-const card = getCreditCard(); +const card = getCreditCard("v2");

// And of cource the consumer would need to create a new handler to handle the new credit card type //Argument of type 'CreditCardV2' is not assignable to parameter of type 'CreditCard'. processCard(card);

OpenAPI/REST

In a REST API you can make use of content negotiation to allow the client to opt-in to receiving the updated version, via the Accept request header.

eg. if your OpenAPI initially looked like this:

openapi: 3.0.3
info:
  title: Credit Card API
  version: 1.0.0
paths:
  /credit-card:
    get:
      summary: Get credit card scheme
      responses:
        '200':
          description: Credit card scheme object
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditCard'
components:
  schemas:
    CreditCard:
      type: object
      properties:
        scheme:
          type: string
          enum:
            - Visa
            - Mastercard

You could extend the spec like this:

 openapi: 3.0.3
 info:
   title: Credit Card API
   version: 1.0.0
 paths:
   /credit-card:
     get:
       summary: Get credit card scheme
       responses:
         '200':
           description: Credit card scheme object
           content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditCard'
+            application/vnd.credit-card+json; version=2:
+              schema:
+                $ref: '#/components/schemas/CreditCardV2'
 components:
   schemas:
     CreditCard:
       type: object
       properties:
         scheme:
           type: string
           enum:
             - Visa
             - Mastercard
       required:
         - scheme
+    CreditCardV2:
+      type: object
+      properties:
+        scheme:
+          type: string
+          enum:
+            - Visa
+            - Mastercard
+            - Amex
+      required:
+        -

Graphql

In graphql you would extend your schema to have a second version of the credit card field to represent the updated object. Initially:

type CreditCard {
  scheme: CreditCardScheme!
}

enum CreditCardScheme { Visa Mastercard }

type Query { creditCard: CreditCard! }

Extended:

 type CreditCard {
   scheme: CreditCardScheme!
 }

enum CreditCardScheme { Visa Mastercard }

type Query { creditCard: CreditCard! } +type CreditCardV2 { + scheme: CreditCardSchemeV2! +} + +enum CreditCardSchemeV2 { + Visa + Mastercard + Amex +} + type Query { creditCard: CreditCard! + creditCardV2: CreditCardV2! }
June 19, 2025 Score: -1 Rep: 84,846 Quality: Medium Completeness: 60%

It seems to me that this isn't a breaking change in TypeScript. You've just left the default case off your switch.

eg

type CreditCard = {
    scheme: "Visa" | "Mastercard" | "Wtf"
}
function processCard(card: CreditCard): number {
    switch(card.scheme) {
       case "Visa": return 1; 
       case "Mastercard": return 2; 
       default : throw new Error("unknown card type")
    }
}

Essentially this is what in other languages would be a simple warning "you haven't covered all possible cases in this switch statement".

Its great to have it as an error so you don't get runtime errors. But its not a "breaking change" in the sense of changing a method signature.

You are just misusing the type system to do enums.

Your proposed solutions just exasperate the problem by trying to change what you are sending rather than how you use it. Some of them even break the whole concept of having a set of possible values!