Union Type: A Deep Dive
Image by freepik

Union Type: A Deep Dive

If you have been following my posts since last month, you’ve probably seen one of my first LinkedIn slides called “Composing Types.” In it, I explained two ways of composing types into a structure in which the original types remain intact: AND composition and OR composition. In this post, we will dive deep into OR composition, or as the term we are more familiar with, the union type.

Description

Definition

Union type allows multiple types to be composed as alternatives instead of combinations. Only one type from a series of alternatives in a union type can be used by a data.

A simple way to describe a union type is: X type consists of A or B or C. A data type of X can be either A, B, or C, But you can’t choose both A and B. That’s the reason why it is also called OR type. Another name for union type is variant type, enum type, choice type, case type, sum type, and coproduct type.

A very simple and familiar example of a union type is Boolean. It has two alternative values: true and false.

This and the following codes are psuedo-codes by the way.

type Boolean = True | False

let val: Boolean = True // ?
        

Each alternatives in a union type may optionally hold another types. For example, here in Color union type, there are alternatives that carry another types. This is how multiple types are composed in a union type.

type Color =
  | RGB (red: Int, green: Int, blue: Int)
  | HSL (hue: Int, saturation: Float, lightness: Float)
  | Hex (value: String)

let val: Color = HSL(hue: 120, saturation: 0.3, lightness: 0.9) // ?
        

Of course, a union type can have alternatives with or without held types.

type AsyncData =
  | Loading
  | Success (data: Data)
  | Failure (error: Error)
  
// Let's assume Error has a constructor that accepts a string value  
let val: AsyncData = Failure(error: Error("Data not found!")) // ?
        

I’d also like to make it clear that what we’re talking about tagged/discriminated union type here. Basically, the prominent feature of a tagged union is that differentiating each sub-types is as easy as examining its tags. The tags are the unique name for each of the alternatives. In AsyncData type we defined before, the tags are Loading, Success, and Failure.

Possible Values

First of all, if you see types as mathematical sets instead of object-oriented classes, our regular everyday primitive types can be modeled as union type!

type Boolean = True | False
type Int     = ... | -2 | -1 | 0 | 1 | 2 | 3 | ...
type Float   = ... | 0.0 | 0.01 | 0.001 | 0.002 | ...
type String  = "" | "a" | "ab" | "ba" | ...
        

Defining them one by one like that is obviously tedious, inefficient, and impractical. It should never be done in production code! But from that model, we can see that types have their own number of possible values. For example:

  • A boolean has 2 possible values.
  • An unsigned 8-bit integer has possible 256 values, you can imagine how many a 64-bit integer’s is.
  • A floating value and a string both have a lot of possible values, virtually infinite (but not truly infinite).

What about a custom union type? How many possible values are there?

type Actions =
  | NoOp
  | Increment 
  | Decrement
  | Reset
        

There are 4 in the type above, which also equals the number of alternatives it has. However, if there are carried value in an alternative, the possible values of the carried value should be counted instead.

type Actions =
  | NoOp
  | Increment (step: Int)
  | Decrement (step: Int)
  | Reset
        

Supposedly there are 16 possible values in Int, there are 1 + 16 + 16 + 1 = 34 possible values in the Actions type. Is this valid according to the requirement of our program? We should always evaluate that, since in most cases we usually don’t want to put all possible Int values into our type. If the requirements only allow it, we can just let it be Int, commit, push, clock out, and go home. But what if the requirement demands only 1 to 4 as step? Then, we should model them using union as well.

type Actions =
  | NoOp
  | Increment (step: Step)
  | Decrement (step: Step)
  | Reset
	
type Step = One | Two | Three | Four
        

Thus, Actions will only have 1 + 4 + 4 + 1 = 10 possible values.

If the requirement is to insert a lot of possible integer values but still less than what Int type allows, a basic union type will lead Step to have so many cases. For this case, Step should be a single union type of Int, which we will discuss in Use Case section.

While calculating possible values is not always a mandatory thing to do, it can be used as a tool to evaluate whether a type is designed as expected. Does it make sense for your types to have a lot of possible values? If not, can it be simplified?

Struct

Struct is the OG composition structure that most programming languages have. Unlike union type, struct represents combination of multiple types. Therefore, all types in a struct has to exist at the same time.

type Monster = {
  speed: Float
  damage: Float
  name: String
  sprite: Path
}

// Valid, as every field exists in the struct
let fastodon: Monster = {
  speed: 5.2
  damage: 1.0
  name: "Fastodon"
  sprite: Path("/assets/fastodon.gif")
}

// Invalid, missing speed
let slugmare: Monster = {
  damage: 4.2,
  name: "Slugmare",
  sprite: Path("/assets/slugmare.gif")
}
        

Some languages allow structs to have optional fields, which ignores the requirement for a field to exist in a valid struct.

type MonsterWithOptionalSpeed = {
  speed?: Float
  damage: Float
  name: String
  sprite: Path
}

// Valid, even if the speed is missing
let slugmare: MonsterWithOptionalSpeed = {
  damage: 4.2,
  name: "Slugmare",
  sprite: Path("/assets/slugmare.gif")
}
        

Optional field is often abused by developers. If you read through this article, you’ll realize that abusing them can lead to many annoyances and technical debts in the future.

To count the possible values of a struct, use multiplication instead of addition. Supposedly that Float has 8 possible values, String has 16, and Path has 4 (of course, these are not the real possible values. I’m intentionally using smaller numbers so we can digest it easier), there will be 8 × 8 × 16 × 4 = 4.096 possible values in Monster type. However, since MonsterWithOptionalSpeed has optional speed field, there will be 9 possible values in speed instead of 8. Thus, the whole struct will have 9 × 8 × 16 × 4 = 4.608 possible values. That’s an extra 512 possible values just for having an optional field. Now, imagine if all of the fields in the Monster structs are optional! How many possible values are there?

This might be counter-intuitive, but having less optional types actually leads to a simpler struct. Therefore, I suggest that when designing a struct in deep domain code territory, do not be tempted to make fields optional. If there is a need of optional fields, try to model it into a union first, and then the Option type - which will be discussed later. Otherwise, add them sparingly.

From Struct to Union

What if we model the Actions type with a struct instead?

type Actions = {
  is: String
  step: Int
}
        

That looks deceptively simple, but it will cause a lot of pain in the future.

Firstly, the possible value for a struct is evaluated with multiplication instead of addition. Therefore, if String has 32 possible values and Int has 16, we would have 32 × 16 = 512. That’s a lot more than our union type version!

Secondly, the caller should always evaluate is to only allow "NoOp", "Increment", "Decrement", "Reset" every single time! It’s also quite prone to typos as string checking don’t yield compile-time error.

switch (action) {
  case "NoOp":
    // Do NoOp
  case "Increment":
    // Do Increment, utilizing step
  case "Decrement":
    // Do Decrement, utilizing step
  case "Reset":
    // Do Reset
  default:
    // What should we do about this? Throw exception? Do nothing?
}
        

Additionally, we have a confusing case of default. How tedious!

This should gives us an idea that String is not a suitable representation for is field in the Action type. Let’s convert it to a union!

type Actions = {
  is: NoOp | Increment | Decrement | Reset
  step: Int
}
        

Now, we have Actions that:

  • Has a possible values of 4 × 16 = 64, much less than the previous 512
  • Always has valid is value. When the user of Actions type is typing the wrong type, they will get compile-time error instead of silently running with runtime bugs.
  • Does not need to be evaluated with default case

Lastly, step should only be available of Increment and Decrement type of action. But now, it is still possible for another types to have step. While it may not break the program, it is unnecessary and might lead to inconveniences down the line. For example, if a user want to create an action of Reset type, they will need to include step even though it is not necessary!

let action: Actions = {
  is: Reset
  step: ... // Wait, why should we provide the step again?
}
        

So, just make step optional then?

type Actions = {
  is: NoOp | Increment | Decrement | Reset
  step?: Int
}
        

With this solution, a new set of problem arises!

  • It is now possible to build action of type Increment or Decrement without step, which should be unexpected.
  • Checking the action type of Increment and Decrement will give us optional step, which is also not expected.

// No step?
let action: Actions = {
  is: Increment
}

if (action.is == Increment) {
  action.step // step can be missing!
}
        

A better solution would be putting step as a carried value for Increment and Decrement.

type Actions = {
  is: NoOp | Increment (step: Int) | Decrement (step: Int) | Reset
}
        

Now, there is no reason to make Actions a struct. Let’s just flatten it into a union.

type Actions =
  | NoOp
  | Increment (step: Int)
  | Decrement (step: Int)
  | Reset
        

Construction and Extraction

In struct, construction is usually done with the language’s inbuilt symbol like { and }, or writing the struct’s name followed by its members. In our pseudo language, the first approach is used as demonstrated by previous examples.

Extraction is usually done via . operator or other symbols. I rarely found other symbols used, but sometimes there are languages that uses :: or ->. But let’s keep on . to make it loss confusing.

type Monster = {
  speed: Float
  damage: Float
  name: String
  sprite: Path
}

// Struct construction
let fastodon: Monster = {
  speed: 5.2
  damage: 1.0
  name: "Fastodon"
  sprite: Path("/assets/fastodon.gif")
}

// Struct extraction
let speed = fastodon.speed
        

Hopefully that clears things up about construction and extraction!

Now, how about union types?

Union type construction is usually done by writing the type constructor of one of the alternatives. A type constructor is basically a function that accepts the carried types as its parameters and return the constructed union type. If this confuses you, don’t worry. Check the example below instead:

type Actions =
  | NoOp
  | Increment (step: Int)
  | Decrement (step: Int)
  | Reset
  
let noopAction: Actions = NoOp()

let incrementAction: Actions = Increment(
  step: 15
)
        

As you can see, each alternatives implicitly produces a type constructor function. Most languages that support union type do this, while some that only partially supports it, like TypeScript, do not. More about this in the Implementation section.

Lastly, how to extract a value from a union type?

The answer is via pattern matching. Does that term sound familiar to you? It is a very powerful language feature that allows us to check if a statement contain a characteristic that we want to extract. Not to be confused with Regular Expression, as pattern matching can be interacted to not just string, but almost all data structures. Pattern matching comes in a lot of flavor, but only if and match will be discussed here, as they are usually the most common expressions for union type extraction.

First, let’s take a look at pattern matching inside if :

if (action is NoOp) {
  // Do something on noop (no operation) action
}

if (action is Increment(step)) {
  // Do something on increment action, and we can get the `step` value!
}
        

Here, a union type data of action is checked against NoOp and Increment. NoOp does not have any value, thus there is nothing to extract from it. In the Increment checking part, however, the value of step can be extracted, as they are present on Increment and Decrement.

Now let’s see on match, which something you might have not heard before:

match action with 
  | NoOp => // Do something on noop action
  | Increment(step) => // Do something on increment action, extracting the `step` value
        

This is the match expression, which is the defacto way to directly extract a union type. This expression allows us to match a value against multiple patterns. In the example above, action data is matched against NoOp and Increment, which in the latter, step value can be extracted. Doing match against a union type usually should be exhaustive, which means, all alternatives should be provided. Else, the compiler will not allow it to compile. That’s right, the above solution actually won’t compile in most languages. These, however, will compile just fine:


// An exhaustive match
match action with
  | NoOp => ...
  | Increment(step) => ...
  | Decrement(step) => ...
  | Reset => ...

// Also an exhaustive match, with a "catch-all" pattern
match action with 
  | NoOp => ...
  | Increment(step) => ...
  | _ => // Do something on other actions that we don't care to handle in this part
        

match expression looks very similar to switch statement, because they are. switch can be seen as a weaker version match. Some languages that have regular switch statement at first, usually adopt match into switch , such as Java, C#, and Dart. Other languages, like PHP, add an entirely new match expression without nudging the current switch statement. That’s right, PHP has match expression before JavaScript!

Use Case

In real life, there are many instances where union type is more suited than structs. You might have encountered some of these instances on your projects.

Prevent Impossible Case

Take a look at this type:

type User = {
  email: String
  password: String
  phoneNumber: String
}
        

A new requirement dictates that users can still be valid even if they only have an email or a phone number. However, password is only available to users with email.

If you read the previous sections, you know this isn’t the best solution:

type User = {
  email?: String
  password?: String
  phoneNumber?: String
}

// Should be an invalid user, as password is not present.
let user1: User = {
  email: "[email protected]"
}

// Should be an invalid user, as password should not be present without email.
let user2: User = {
  password: "hashedpasswordblablabla"
  phoneNumber: "+6287715085671"
}

// Should be an invalid user, obviously.
let user3: User = {}

// But none of them is a compilation error!
        

“But Deta, we can still check user’s data using implementation code.”

That’s correct. We can do that! It is how we usually do it, right?

However, it is always better to catch errors on compile-time than runtime. Compile time error can be immediately identified, found, and fixed. The program will not run and the error won’t be delivered to the users should there be a compile time error when compiling our code. If you’ve been programming real production code for at least a year, you should have an experience on how onerous, time consuming, and brain damaging it is to debug a runtime error. Compile time error is just cheaper than runtime error.

One of the points of having types, especially static types, is to minimize runtime error by making illegal states unrepresentable. With a proper use of union type, a group of runtime errors can be even eliminated, which will save us a lot of time in the long run.

Now let’s get back into our code. First of all, our new user actually has 3 possibilities:

  • Has email and password
  • Has phone number
  • Has both or complete

Therefore, we can model them with a union type:

type User =
  | EmailOnly (email: String, password: String)
  | PhoneNumberOnly (phoneNumber: String)
  | Complete (email: String, password: String, phoneNumber: String)
        

Let’s test the type by creating a data of it. Does it represent the valid ones and prevent the invalid ones?

// User only has email, thus, password is required
let validUser1: User = EmailOnly(
  email: "[email protected]",
  password: "hashedpasswordblablabla"
)

// User only has phone number
let validUser2: User = PhoneNumberOnly(
  phoneNumber: "+6287715085671"
)

// User has both email and phone number, thus, password is required
let validUser3: User = Complete(
  email: "[email protected]",
  password: "hashedpasswordblablabla",
  phoneNumber: "+6287715085671"
)

// Error: HasEmail requires password!
let invalidUser1: User = EmailOnly(
  email: "[email protected]"
)

// Error: HasPhoneNumber does not have password field!
let invalidUser2: User = PhoneNumberOnly(
  password: "hashedpasswordblablabla",
  phoneNumber: "+6287715085671"
)

let invalidUser3: User = ... // We can't even model the empty user!

// Great! Now, invalid user data will prevent the code from compiling.
        

The answer is yes!

“But Deta, it is more complicated to handle a union type than a struct. Our new programmers won’t be able to understand it.”

I would say, the struct and type union version are equally complicated, as the domain problem is pretty complicated. It has three possible kind of users instead of one! It now depends on us, whether we want to bring the burden of complication into types or the code implementation. I choose to bring it to the former, as I mentioned before.

This might be a new thing for new programmers, especially if their first language doesn’t allow union types easily, which unfortunately, most first languages are in this category. However, with the trend of newer programming languages becoming more hybridized, influenced by functional programming and algebraic data type - which is where the union type comes from. It’s about time for the union type to become a norm, sitting next to classes and structs. Thus, we should be prepared, don’t you agree?

Substituting Confusing Boolean

Take a look at this type:

type Button = {
  ghost: Boolean
  solid: Boolean
  outline: Boolean
  primary: Boolean
  secondary: Boolean
  danger: Boolean
}
        

If you’re a frontend engineer, most likely you have encountered this kind of API. It might be coming from a UI framework or library you’re using.

The problem with having so many booleans is, once again, impossible states can easily show up. For example, it shouldn’t be possible to create a button that is both ghost, solid, and outline. But the type allows you to do it. What would be the button look like if you do that, would it have undefined behavior, prioritize the solid style, or simply break on runtime? That information is usually stored in the comments or documentation of the API, because the types can’t describe those important tidbits.

With union type, that confusing case will simply be gone. Because it is impossible to create a button that is both ghost, solid, and outline.

type Button = {
  fill: Solid | Ghost | Outline
  primary: Boolean
  secondary: Boolean
  danger: Boolean
}
        

Now, what about the primary, secondary, and danger fields? Well, we should put them into a union type too. It does not make any sense for a button to be both primary, secondary and danger, doesn’t it?

type Button = {
  fill: Solid | Ghost | Outline
  variant: Primary | Secondary | Danger
}
        

Now it is much better! It should be possible for a button to be both solid and primary, ghost and primary, etc. fill and variant are two different concepts that can coexist together - except if the UI library we’re using says otherwise. Therefore, there is no need to merge the two unions.

Optional & Result Value

Sometimes, there is a need of a type where nothing is expected aside from the real value. By leveraging union type, it is possible to represent non-existence elegantly without having to touch the dreaded null. Introducing, the Option type:

type Option<T> = 
  | Some (value: T)
  | None
        

Option type is a union type with two alternative values: Some and None.

  • Some represents the existence of a value
  • None represents the non-existence

This is not “null with extra step.” A lot of languages are not sound enough that a null can slip through the types, which leads to no compile time error at all. A wrong usage of a union type, however, will not compile, preventing the faulty code from running. This allows developer to fix them as soon as possible instead of letting it to run on client machine - and you know what can happen next.

Let’s see an example of how Option type is used:

type Monster = {
  speed: Option<Float>
  damage: Float
  name: String
  sprite: Path
}

// Instead of not including speed, put `None` instead
let slugmare: Monster = {
  speed: None,
  damage: 4.2,
  name: "Slugmare",
  sprite: Path("/assets/slugmare.gif")
}
        

It is also very useful for modelling some function return value, such as Find:

// ---- List Library Code ---

type Find<T> = (list: List<T>, predicate: Predicate<T>) => Option<T>
let find: Find<T> = ... // Implementation of find

// ---- Client Code -----

let monsters: List<Monster> = [
  { name: "Slugmare", speed: None, damage: 4.2, sprite: Path("/assets/slugmare.gif") },
  { name: "Fastodon", speed: Some(5.2), damage: 1.0, sprite: Path("/assets/fastodon.gif") },
]

// This variable is Option<Monster>, which indicates that the monster might not be found.
let maybeSlugmare = find(monsters, monster => monster.name == "Slugmare")

maybeSlugmare.name // We can't do this, as it is possibly None!

// We have to extract it using match expression, then we can obtain the monster.
let name = match maybeSlugmare with
  | Some(slugmare) => slugmare.name
  | None => "Monster not found!"
        

If you’re lucky, your main language might already have something similar to Option and Result. If not, most likely there are geniuses out there who already published packages to handle that case. I’m glad that now this pattern becomes popular, and we stray away from the problematic null and reduce the usage of exceptions for good.

Why are null and exception are more problematic than useful? This requires its own article, unfortunately. I’ll put it on my writing backlog and see if I can publish it this year.

Single Union Type

Check this code out:

let email1: String = "[email protected]"
let email2: String = "asfasfasf"
let email3: String = ""
        

Is email address a string? Well, it can be represented as string, but not all possible strings are email addresses. Therefore, it might not be a good idea to represent an email address with string if you don’t want this to compile:

let sendEmail = (email: String, message: String) => {
  // Send email
}

sendEmail("dangerous-string-that-is-not-email-??", "some message")
        

Let’s create a dedicated type for email address using union type.

type EmailAddress = 
  | private EmailAddress(value: String)
  
let parseEmail = (value: String): Option<EmailAddress> => {
  // Parse a string and check if it is an email address
} 
        

An email address is represented by a type of EmailAddress, which is a union type that only has a single alternative. However, the creation of this type should only be possible via parseEmail. To achieve this, we can privatize the single case to only make it available on module scope. This will effectively prevent the user of this type to create an invalid email address!

Now, the sendEmail function can be written as such:

let sendEmail = (email: EmailAddress, message: String) => {
  // Send email
}

// Compile Error! String is not EmailAddress!
sendEmail("dangerous-string-that-is-not-email-??", "some message")

// Compile Error! Option<EmailAddress> is not EmailAddress!
sendEmail(parseEmail("dangerous-string-that-is-not-email-??"), "some message")

// The only way to get the email value is by pattern matching it.
// This guarantees no bad address is being sent into the implementation
// of sendEmail function
let maybeEmail = parseEmail("dangerous-string-that-is-not-email-??")
match maybeEmail with
  | Some(email) => sendEmail(email, "some message")
  | None => println("Invalid email!")
        

This solution is the best where you need something that is not exactly the same as an already existing type, such as primitives, but still has a lot of possible values that writing each of them case by case is just impossible. This is also an excellent antidote to primitive obsession, which is one of the most overlooked code smells.

However, this solution is not purely on type like the previous cases. It is often necessary to create a “parser” function to construct the type, which evaluates an existing type and return either an Option or Result. This makes sure no bad value is created. Also, it is quite necessary for the language to support some sort of module system where we can define a module scoped value and publicly available value. Very similar to Object Oriented Programming’s encapsulation system, but without the need of creating classes to achieve it. If everything is public, then it is still possible for bad values to slip through!

// If the case is not privatized, other code can do this, bypassing the parsing step!
EmailAddress(value: "dangerous-string-that-is-not-email-??")
        

Implementation

How is union type implemented by popular programming languages?

TypeScript

I’m going to start with TypeScript, as it is my main language (sigh~). If you haven’t noticed already, I write all the codes in the previous sections using pseudo-code instead of TypeScript. Why? Because TypeScript is definitely not the best language to demonstrate the power of union type. Let’s dig more into it for more clarity.

First, TypeScript does support untagged union type out of the box, but not tagged union type. However, it is possible to combine untagged union, objects and literal values to create a contraption that mimics tagged union type.

// The discriminator property for Action can be anything, really.
// I prefer to use "kind" to make it distinct from "type"
type Action =
  | { kind: "noop" }
  | { kind: "increment", step: number }
  | { kind: "decrement", step: number }
  | { kind: "reset" };
  
const noopAction: Action = {
  kind: "noop",
};

const incrementAction: Action = {
  kind: "increment",
  step: 3,
}
        

While there is enum in TypeScript, it isn’t really the same as tagged union. It can’t carry different types per cases. The union-object-literal fusion is just closer to tagged union type than enum could ever dream of!

Another frustrating fact about TypeScript is that the implementation of union type does not include type constructor. Either you have to write the object yourself like the above example, which is not composable, or create type constructor function yourself - for each cases! This is also one of the reason why Redux has a huge boilerplate, as we need to write every action creators every time. If this feature was built into the language, I would go all in to say that the original Redux would not be as loathed by the community.

// Languages that have full support of tagged union have
// these type constructor functions built-in to each cases. 
function NoOp(): Action {
  return { kind: "noop" };
}

function Increment(step: number): Action {
  return { kind: "increment", step };
}

function Decrement(step: number): Action {
  return { kind: "decrement", step };
}

function Reset(): Action {
  return { kind: "reset" };
}
        

If this doesn’t frustrate us enough, TypeScript doesn’t have match expression. However, its if and switch statements are smart enough to detect carried values inside a union type.

const action = Increment(2);

// This will result in error, since TypeScript cannot tell if the action 
// is `noop`, `increment`. `decrement`, or `reset`. 
console.log(action.step);

if (action.kind === "increment") {
  // Since we already check the `kind` as `"increment"`, TypeScript will
  // intelligently infer that `action` has `step`.
  console.log(action.step);
}

// Same as if, TypeScript will infer based on the `kind` value 
switch (action.kind) {
  case "noop":
    // step is inaccessible here...
  case "increment":
    // step is accessible here...
  case "decrement":
    // step is accessible here...
  case "reset"
    // step is inaccessible here...
  default:
    // Impossble, since all possibilities have been covered.
}
        

Unfortunately, TypeScript’s if and switch are statements, not expressions. For those who want to minimize side-effectful code, this isn’t a good news. To mimic something like match, I usually create something like this:

function matchAction<T>(action: Action, cases: {
  noOp: () => T,
  increment: (step: number) => T,
  decrement: (step: number) => T,
  reset: () => T,
}) {
  // The more cases you have, the more painful it is.
  switch (action.kind) {
    case "noop":
      return action.noOp();
    case "increment":
      return action.increment(action.step);
    case "decrement":
      return action.decrement(action.step);
    case "reset"
      return action.reset();
  }
}

// Usage.
const step = matchAction(action, {
  noOp: () => 0,
  increment: step => step,
  decrement: step => step,
  reset: () => 0,
});
        

Other Languages

TBD

Epilogue

That was a long dive into the world of union type. But I believe it was worth it!

I hope this article helped you in understanding union types, why we need it, and how to do it in several languages. Keep in mind that union type is not a total replacement for existing struct types. Both have their own uses, and both can be used in tandem. Union type is very helpful in modelling possibilities, preventing impossible types from emerging. If everything needs to exist in a single structure, then use the good ol’ struct. Being mindful about possible values of a type can help us to decide which type to use. The language used is also a significant factor too, as some may not support it very well. In the end, its up to you and your team whether you want to adopt union type into your code base or not.

Thanks for reading! See you again in the next article~

要查看或添加评论,请登录

Deta Aditya的更多文章

  • Modelling software with types! (Part 2)

    Modelling software with types! (Part 2)

    ?? Previous Part In this part, we will continue to model the UNO Card Game we have designed in the 1st part. Here, we…

  • Modelling software with types! (Part 1)

    Modelling software with types! (Part 1)

    How do you usually model your software before it is developed into code? Some people uses diagrams, whiteboard…

社区洞察

其他会员也浏览了