Created
August 2, 2020 00:20
-
-
Save mvaldesdeleon/725fc81d52a44c20525c41e59093013f to your computer and use it in GitHub Desktop.
Building with Types, draft
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Building with Types | |
// Lets imagine we have two types, for example Number and String. | |
// In which ways could we combine them, just one of each, to create a different type? | |
// How could we do this? | |
// Well... we could create an object: | |
type Person = {name: string, age: number}; | |
// And this would mean a Person contains both a string and a number. It also has specific names for each of these values. | |
// The string is called 'name', and the number is called 'age'. | |
// Could we combine them in a different way? | |
// Could we drop the names, for example? | |
// Yes we could... with a tuple: | |
type Tuple = [string, number]; | |
// Now we still have both a string and a number, but we no longer have specific names for these values. | |
// Instead, we have indexes or positions. | |
// But overall, these two types express the same thing: a conjunction of its constituent types. | |
// And in both cases, they can be extended with as many additional types as required, with the only restriction being | |
// that object fields must have unique names. | |
// Is this all? What else could we try changing? | |
// Well... so far, the two types we created both contain a string and a number at the same time. | |
// What if we wanted to indicate the opposite? | |
// What if we wanted a type that could be either a string or a number? | |
// Well... we could use a union type for that: | |
type Union = string | number; | |
// Opposite of the two previous types, a union type expresses a disjunction of its constituent types. | |
// When dealing with values of this type, we would need to narrow them down to a specific type before operating on them: | |
function dealWithIt(u: Union): number { | |
switch (typeof u) { | |
case 'string': | |
return u.length; | |
case 'number': | |
return u; | |
} | |
} | |
// This has one limitation: What if the two types are the same type? What if we don't even know the types (generics)? | |
// In this case, a plain union will not work. | |
// Lucky for us, TypeScript introduces the concept of Tagged or Discriminated Unions: | |
type TaggedUnion = { tag: 'euro', value: number } | { tag: 'dollar', value: number }; | |
// In this case, we wrap the value we're interested in an object, and add a discriminating tag to each of the options. | |
// Once again, we would need to narrow the type down by inspecting the tag when dealing with values of this type: | |
function dealWithItAgain(u: TaggedUnion): number { | |
switch (u.tag) { | |
case 'euro': | |
return u.value; | |
case 'dollar': | |
return u.value; | |
} | |
} | |
// In both plain and tagged unions, using a switch as the top-level statement has two benefits: | |
// By specifying a return type and enabling --strictNullChecks, we gain exhaustiveness checking. This results in errors | |
// when we forget to consider all of the branches of our union. | |
// As a consequence, this also forces us to handle all branches at the same time. We should not deal with a value | |
// of a union type, without acknowledging all the possibilities of said union. We can chose to return the value unmodified | |
// for all branches except for one, but this needs to be an explicit decision, and not a consequence of ignoring all other | |
// branches and hoping for the best. | |
// This technique is called "pattern matching", and can be seen as a way of destructuring a value of a union type. | |
// And it might seem surprising, or even underwhelming, but all we need moving forward to build with types, are these two | |
// tools for combining types: conjunction and disjunction. | |
// Many languages have ways of expressing conjunction, via structures, classes, records, dictionaries, etc. | |
// which are all powerful tools for building with types. | |
// But when it comes to disjunction, most languages come up short, and only offer booleans and enumerations. | |
// This causes us to create types that in turn enable us to make mistakes. | |
// And the reason we can make those mistakes, is because the types do not fully capture our business domain | |
// neither do they enforce our existing business rules. | |
// For our first example, we'll work with an existing object: | |
type Person0 = { | |
name: string, | |
age: number | |
}; | |
// And we're tasked with adding a new field, so we can store an IP Address. And we're told to do this using a string. | |
// So we do it. | |
type Person1 = { | |
name: string, | |
age: number, | |
ipAddress: string | |
}; | |
// Oh, but they forgot to tell you, the IP Address can be either IPV4 or IPV6, so you also need to store this somehow. | |
// No problem, we say, and we do it. | |
type Person2 = { | |
name: string, | |
age: number, | |
ipAddress: string, | |
ipv4: boolean | |
}; | |
// And we might write this and think that this accurately captures all of the information we were asked to store. | |
// Which it does. | |
// But at the same time, this type does nothing to keep us from mixing things up, and storing an IPV4 IP Address, | |
// while keeping the ipv4 flag set to false. | |
// At this point, with a traditional OOP language you would start to look into getters and setters, for example | |
// making ipv4 a read-only field, and updating it within the ipAddress setter. This means that your consumers | |
// can no longer create these invalid values. However, you still can. | |
// What we're being asked to model is a disjunction. So why not use the tool that TypeScript provides for that exact purpose? | |
type IPAddress = { readonly tag: 'ipv4', value: string } | { readonly tag: 'ipv6', value: string }; | |
type Person3 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress | |
}; | |
// Making the tags readonly further reduces the chance of errors, as it requires a new value to be created explicitly, | |
// should you want to update a tag. | |
// We would still need to enforce our business rules, but instead of using getters and setters, a plain function can do the job: | |
function IPAddress(ipAddress: string): IPAddress | null { | |
if (ipAddress.includes('.')) { | |
return { tag: 'ipv4', value: ipAddress }; | |
} else if (ipAddress.includes(':')) { | |
return { tag: 'ipv6', value: ipAddress }; | |
} else { | |
return null; | |
} | |
} | |
// Lets look at another example. The next sprint comes along, and we're now tasked with extending our existing object with | |
// some contact information. And a person can choose between three different contact methods: Email, Phone or Mobile. | |
// 10 minutes ago, we would've very quickly reached out for a solution like this: | |
enum ContactMethodType {Email, Phone, Mobile} | |
type Person4 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
email: string | null, | |
phone: string | null, | |
mobile: string | null, | |
contactMethodType: ContactMethodType | |
}; | |
// But now we should be able to look at this and see the potential problems. One one hand, we can get things mixed up again, | |
// and have the actual contact method stored be out of sync with what the contactMethodType field indicates. | |
// We also have two fields that, for a well-formed value, will always be null yet could be accessed by mistake. | |
// Finally, a well-formed value could still have all three contact method fields set to null. | |
// Of course, we know how to address these issues: | |
type ContactMethod = { readonly tag: 'email', value: string } | |
| { readonly tag: 'phone', value: string } | |
| { readonly tag: 'mobile', value: string }; | |
function Email(email: string): ContactMethod | null { | |
if (email.includes('@')) { | |
return { tag: 'email', value: email }; | |
} | |
} | |
type Person5 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
contactMethod: ContactMethod | |
}; | |
// So by this point we're quite comfortable modeling disjunction, and we can avoid having unnecessary fields that could introduce | |
// errors, or values that become internally out of sync with themselves. | |
// We're ready for whatever comes next, so we wait eagerly for the next feature request, until it finally arrives. | |
// The new contact information feature is a hit, and we've been asked to extend this feature to allow multiple contact | |
// methods to be stored for a given person. Contact methods can now be either primary or secondary, and every person must have | |
// a single primary contact method. | |
// Alright, another disjunction, we can do these with our eyes closed by now, right? | |
type ExtendedContactMethod = { tag: 'primary', value: ContactMethod } | { tag: 'secondary', value: ContactMethod }; | |
type Person6 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
contactMethods: [ExtendedContactMethod] | |
}; | |
// But this is not quite right, is it? | |
// In chasing the disjunction, we missed the rest of our business rules. There's no way to restrict a person to a single | |
// primary contact method. And there's no way to guarantee that a primary contact method will exist either. Either all | |
// contact methods could be secondary, or there might not be any contact methods at all. | |
// As it turns out, while a single part of the new requirement expressed a disjunction, the overall requirement | |
// would be better modeled by a conjunction of a single primary contact method, and a list of secondary contact methods: | |
type Person7 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
primaryContactMethod: ContactMethod, | |
secondaryContactMethods: [ContactMethod] | |
}; | |
// Note that we're no longer using our ExtendedContactMethod type, but rather we're back to our original ContactMethod type. | |
// A few more sprints go by, and once again, a new feature request comes along. We'll be rolling out a survey soon, and we'll | |
// need to store the results for each question, which can be a number from 0 to 5. | |
// Based on our prior research, we want to allow the persons to complete the survey in any order they please, and not | |
// necessarily in one single sitting. To provide a good user experience, when the persons return to the survey they should be able | |
// to pick continue from the question they left off. | |
// Boring, we think. This one is easy, an array and an index, done. | |
type Person8 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
primaryContactMethod: ContactMethod, | |
secondaryContactMethods: [ContactMethod], | |
currentAnswer: number, | |
answers: [number | null] | |
}; | |
// But of course, there are problems: an easy one, and a not so easy one. The easy one is that the current type allows answers | |
// that our business rules do not allow, such as 9, 1.4 or -4. And we say this one is easy to solve because this is just | |
// another disjunction: | |
type Answer = 0 | 1 | 2 | 3 | 4 | 5; | |
type Person9 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
primaryContactMethod: ContactMethod, | |
secondaryContactMethods: [ContactMethod], | |
currentAnswer: number, | |
answers: [Answer | null] | |
}; | |
// The second one is that once again we have two tightly coupled fields that can run out of sync with each-other if we | |
// mess things up. With this representation, there is no way to ensure currentAnswer will be a valid index for answers. | |
// So, how could we solve this? We need to find a representation that does not rely on storing an index to a list, so no numbers. | |
// How about what we did for the contact information? How would that look? | |
type Person10 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
primaryContactMethod: ContactMethod, | |
secondaryContactMethods: [ContactMethod], | |
currentAnswer: Answer | null, | |
answers: [Answer | null] | |
}; | |
// This one is interesting: We no longer have the problem with the invalid index values, since we got rid of it. | |
// And we definitely have a current answer selected, as well as a list of all the other answers. | |
// But we lost some information along the way. | |
// How do we advance to the next or previous answer? All we could reasonably do is insert | |
// the currentAnswer on one end of answers, and take a new currentAnswer from the other end. | |
// Which answer is the first one and which one is the last? There's no longer any way to tell. | |
// If only we could tell which answers came before the currentAnswer, and which came after, right? | |
// Well, let's do just that. | |
type Person11 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
primaryContactMethod: ContactMethod, | |
secondaryContactMethods: [ContactMethod], | |
prevAnswers: [Answer | null], | |
currentAnswer: Answer | null, | |
nextAnswers: [Answer | null] | |
}; | |
// With this representation, advancing to the next or previous answer becomes clear: append the currentAnswer to the end of | |
// prevAnswers, and take the first answer from nextAnswers, or append it to the beginning of nextAnswers, and take the last | |
// answer from prevAnswers. | |
// So we see that our new type that is able to enforce the required business logic did not come for free: We will have to deal | |
// with this additional complexity when manipulating values of this type, compared to our initial implementation. | |
// Finally, we can extract some of the work we did into its own types so that they can be reused, cleaning up our code | |
// in the process. | |
// For optional values, rather than creating a plain union with null, we can use a tagged union instead. | |
// This has the benefit of allowing null as one of the "valid" values, without running into unexpected problems. | |
type Maybe<A> = { readonly tag: 'just', readonly value: A } | { readonly tag: 'nothing' }; | |
// We can also extract our contact information implementation into a non-empty list type: | |
type NonEmptyList<A> = {head: A, tail: [A]}; | |
// And our answers list implementation into what's known as a "zip list" type: | |
type ZipList<A> = {prev: [A], curr: A, next: [A]}; | |
// And with this, we can review our Person type one last time: | |
type Person12 = { | |
name: string, | |
age: number, | |
ipAddress: IPAddress, | |
contactMethods: NonEmptyList<ContactMethod>, | |
answers: ZipList<Maybe<Answer>> | |
}; | |
// Bonus track | |
// More types, for inspiration: | |
type Either<A, B> = { readonly tag: 'left', readonly value: A } | { readonly tag: 'right', readonly value: B }; | |
type Result<A, B> = { readonly tag: 'failure', readonly value: A } | { readonly tag: 'success', readonly value: B }; | |
type Validation<A, B> = { readonly tag: 'failure', readonly value: [A] } | { readonly tag: 'success', readonly value: B }; | |
type RemoteData<A, B> = { readonly tag: 'not-asked' } | |
| { readonly tag: 'loading' } | |
| { readonly tag: 'failure', readonly value: A } | |
| { readonly tag: 'success', readonly value: B }; | |
// Newtypes, for dealing with primitive types "with meaning": | |
type Name = { readonly tag: 'name', readonly value: string }; | |
function Name(name: string): Name { | |
return { tag: 'name', value: name }; | |
} | |
type Phone = { readonly tag: 'phone', readonly value: string }; | |
function Phone(name: string): Phone { | |
return { tag: 'phone', value: name }; | |
}; | |
function nameLength(n: Name): number { | |
return n.value.length; | |
} | |
nameLength(Name('asd')); | |
nameLength(Phone('asd')); | |
// Ad-hoc string literal unions, for dealing with booleans "with meaning": | |
type Keep = 'keep' | 'discard'; | |
function filter<A>(list: A[], f: (a: A) => Keep): A[] { | |
return list.filter(a => f(a) === 'keep'); | |
} | |
// More domain modeling, for inspiration: | |
// First implementation. | |
// The board size is not enforced. The actual values present in the board are not specified. | |
// Alternation of moves between players is not enforced. | |
type TicTacToe1 = { | |
board: string[][], | |
next: string | |
}; | |
// Restrict the moves to only the two players available. | |
// Actual values present in board are now specified. | |
type Move = 'X' | 'O'; | |
type TicTacToe2 = { | |
board: (Move | null)[][], | |
next: Move | |
}; | |
// Restrict the board size. | |
// Board size is now enforced. | |
type Pos = 0 | 1 | 2; | |
// List of moves. | |
// Two moves could occupy the same position. | |
type TicTacToe3 = { | |
moves: [Pos, Pos, Move][] | |
} | |
// Fixed-size Arrays. | |
type TicTacToe4 = { | |
board: { | |
[k in Pos]: { | |
[k in Pos]: Move | null | |
} | |
}, | |
next: Move | |
}; | |
// Ordered set of moves, with implicit alternation. | |
// No two moves can occupy the sme position. | |
// Alternation of moves between players is implicity in the type. | |
type TicTacToe5 = { | |
moves: Set<[Pos, Pos]>, | |
first: Move | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment