Skip to content

Instantly share code, notes, and snippets.

@mvaldesdeleon
Created August 2, 2020 00:20
Show Gist options
  • Save mvaldesdeleon/725fc81d52a44c20525c41e59093013f to your computer and use it in GitHub Desktop.
Save mvaldesdeleon/725fc81d52a44c20525c41e59093013f to your computer and use it in GitHub Desktop.
Building with Types, draft
// 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