(This is a fork of Michael's original gist with some editing of the unions + records sections.)
Each of these examples assume the usage of --strict
mode in Typescript
Can be implemented using “Custom Types” https://guide.elm-lang.org/types/custom_types.html
type Size = Small | Medium | Large
size = Medium
A custom type where none of the variants have any attached data is essentially an enum.
You'll get an error if you make a typo:
size = Samll
I cannot find a `Samll` constructor:
19| size = Samll
^^^^^
These names seem close though:
Small
False
LT
Large
You wouldn't get a compiler error if you just used a String.
Can be implemented with either:
https://www.typescriptlang.org/docs/handbook/enums.html
enum Size {
Small,
Medium,
Large,
}
const size = Size.Medium;
https://www.typescriptlang.org/docs/handbook/advanced-types.html
type Size = "small" | "medium" | "large"
const size = "medium"
There are lots of different names for these things in different programming languages. Naming things must be really hard. These things are like enums, but each variant can have data of any type attached to it.
Now called a "Custom Type". Was called a “Union Type” prior to Elm 0.19. https://guide.elm-lang.org/types/custom_types.html
A example showing that custom type variants can either have no data attached (Anonymous
), data of the same type as another variant (Regular
and Visitor
), or data of a different type (Autogenerated
):
type User =
Anonymous
| Regular String
| Vistor String
| Autogenerated Int
user = Visitor "HAL 9000"
Called "Discriminated Union Types". You pick a field, eg. kind
or type
or whatever as a "type tag" to discriminate on. To match the Elm example above:
type User =
| { kind: 'anonymous' }
| { kind: 'regular', name: string }
| { kind: 'visitor', name: string }
| { kind: 'autogenerated', name: number };
const badComputer: User = { kind: “visitor”, name: “HAL 9000” };
You can also build these up piecemeal from type aliases and interfaces, if you want:
type CarOrBicycle = Car | Bicycle;
type Car = { type: 'car', primaryLicenseHolder: Person, mileage: string };
interface Bicycle {
type: 'bike';
tiresAreFlat: boolean;
}
const myBike: CarOrBicycle = { type: 'bike', tiresAreFlat: true };
Both the Elm and and Typescript compilers can check whether you've handled every possible case of an enum or union type.
Exhaustiveness checking in Elm is opt-out
type User =
Regular String
| Visitor String
sayHello user =
case user of
Regular name ->
"hello " ++ name
sayHello (Visitor "hal")
The compiler will not allow this. You must handle every possible case. The compiler will throw this error:
This `case` does not have branches for all possibilities:
25|> case user of
26|> Regular name ->
27|> "hello " ++ name
Missing possibilities include:
Visitor _
I would have to crash if I saw one of those. Add branches for them!
You opt out and cheat the compiler by providing a catch all case. The Seinfeld version:
sayHello user =
case user of
Regular name ->
"hello " ++ name
_ ->
"hello you"
sayHello (Visitor "Hal")
-- returns "hello you"
Exhaustiveness checking in Typescript is opt-in
The following code will not throw any compiler errors, but this function
can unexpedectly return null
at runtime, which would likely result
in a runtime error somewhere.
function sayHello(user: User) {
switch (user.kind) {
case "regular":
return "hello" + user.name;
}
}
sayHello({ kind: "visitor", name: "Hal"}) // this will return null
If you provide a default
switch case and assert that this branch will not be reached, the compiler will provide a compile-time error.
function sayHello(user: User) {
switch (user.kind) {
case "regular":
return "hello" + user.name;
default:
return assertNever(user.kind);
}
}
sayHello({ kind: "vistor", name: "Hal"})
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
The following Typescrip error will be provided:
Argument of type '"visitor"' is not assignable to parameter of type 'never'.
Note that you can also use if else statements instead of a switch statement, but you’ll need to remove the unreachable case once you have fulfilled each option, or you’ll get a compiler error. So, for that reason, switch statements are preferable if you want exhaustiveness checking - which you do ;)
Sometimes you might want a type that can be one of a set of primitive types, such as a number or a string.
type Height = String | Int
This is invalid and will not compile.
If you want to do this you'd need to (somewhat awkardly) tag each option:
type Height =
HeightString String
| HeightInt Int
height = HeightString "150"
-- or
height = HeightInt 150
type Height = number | string;
height = 150;
//or
height = "150"
In elm, an object with a fixed set of fields is called a "Record" (known as a "product type" in some circles). It's similar to a C struct, a Ruby struct or a Python dataclass - it's basically like an OO Class that has no methods. This is quite different from a hashmap or dictionary - if you want something that can have new fields added at runtime, you want a Dict
.
type alias User =
{ username : String
, fullName : String
}
a User
must have a name, an age and nothing else:
hal : User
hal = { username = "Hal" }
-- Compilation error: needs the `fullName` field
hal : User
hal = { username = "hal", fullName = "HAL 9000" }
-- Compiles!
hal : User
hal = { username = "hal", fullName = "HAL 9000", sentient = True }
-- Compilation error: you can't add random extra fields
interface User {
username: string;
fullName: string;
}
Just like Elm records, you'll get a compiler error if you omit or add any fields.
const hal:User = { username: "hal", fullName: "HAL 9000", sentient: True }
// type checker error!
Linked List (slightly off topic: Elm also has Arrays which are indexable)
toppings : List String
toppings = [ "pineapple", "mozarella", "tomato" ]
Array
const toppings:string[] = [ "pineapple", "mozarella", "tomato" ]
// or
const toppings:Array<string> = [ "pineapple", "mozarella", "tomato" ]
Certain functions only make sense when applied to a specific type. For example, a sum
function that adds together every number in a list, only makes sense for a list of numbers (it does not make sense to sum a list of strings).
sum : List String -> Int
sum =
List.foldl (+) 0 -- this won't compile because you can't add two strings.
On the other hand, certain functions can be applied to a wide variety of data types. For example, a list reverse function. It would be a waste of time to re-implement it for every specific type of contained element.
reverseListOfInts : List Int -> List Int
reverseListOfInts xs =
foldl (::) []
reverseListOfStrings : List String -> List String
reverseListOfStrings xs =
foldl (::) []
reverseListOfUsers : List User -> List User
reverseListOfUsers xs =
foldl (::) []
// This is getting really tedious! The implementation is identical for every type of element!
So, rather than declaring a specific type as the element contained in the list, a type variable can be used:
reverse : List thingo -> List thingo
reverse items =
foldl (::) []
The type variable thingo
can be applied to any type such as Int
or String
. This means that this reverse function can be applied to a list of any type. Important note: this does not mean that the list can contain anything such as ["a", 4.5, 10]
, it still means that all items in the list must be the same type, but the function can take a list of any type. You can use any name you want for the variable, such as a
(this is what's conventionally used)
Whereas in Elm type variables are lower case letters like a
, type variables in Typescript are uppercase letters surrounded by angle brackets like <Thingo>
(but <T>
is the convention for a single type variable)
function reverse(items: Array<Thingo>): Array<Thingo> {
... implemention details :D
}
just the one type variable (Elm's core Maybe type is an example of this)
type Maybe a =
Just a
| Nothing
multiple type variables (Elm's core Result type is an example of this)
type Result error value =
Error error
| Value value
What the Elm examples would like like in Typescript
Maybe:
type Maybe<A> = A | undefined;
// or, to use the 'tagged union' structure more faithfully:
type Maybe<A> =
| { type: 'just', value: A }
| { type: 'nothing' }
// eg.
// const x: Maybe<string> = { type: 'just', value: 'hello' };
Result:
type Result<Value, Error> =
| { type: 'ok', value: Value }
| { type: 'error', error: Error }
// eg.
// const x: Result<number, string> = { type: 'ok', value: 123 };
// const y: Result<number, string> = { type: 'error', error: 'DANGER WILL ROBINSON' };
(Like with Elm, type variables can be full words if you'd like; single letters, like T
, or a
in Elm, are just convention.)
Tony Hoare invented null references and now refers to them as his “billion dollar mistake”.
Both Typescript and Elm have ways of asking the compiler enforce that a dreaded null pointer exception will not happen at runtime.
Nulls simply do not not exist in Elm, and not with a different name such a nil or none. It's impossible to get a null/nil/none pointer exception in Elm.
However, you might want to represent the absence of a value. This can be achieved with our friend the Custom Type:
type Maybe a
= Just a
| Nothing
This is the exact implementation of the Maybe type in Elm which comes in the standard library and is automatically imported (no need to import Basics.Maybe
in every file that you use it)
type alias Parent = Maybe String
jesusBiologicalDad : Maybe String
jesusBiologicalDad = Nothing
jesusMum : Maybe String
jesusMum = Just "mary"
Not that for the "success" case, you can't do for example jesusMum = "mary"
. This is because custom types need a tag for each option. In the Nothing case, Nothing
is a tag with no data attached (more technically a "data constructor" with no arguments). Just
is a tag with the "success" value attached.
Similarly, Typescript will forbid assigning a value to undefined
unless you have explicitly allowed it in the type definition.
const x: number;
x = 1; // this is good
x = undefined; // compiler error
const x: number | undefined; // use a union type to allow either a number or undefined
x = 1; // this is good
x = undefined; // this is also OK!
Note that unlike in Elm, you don't need to "tag" the success value with a strange name like "Just" because Typescript allows "untagged unions".
In Elm, an extensible record is less strict than record: it must have certain fields, but it can also have arbitrary additional fields.
type alias Named r = { r | name : String }
sayHello : Named r -> String
sayHello thing =
"hello " ++ thing.name
sayHello { name: "Hal 9000", sentient: True } -- compiles!
True to its name, you can extend other records:
type alias User = Named { id : Int }
This is equivalent to
type alias User = { name : String, id : Int }
There are a few ways to do this in TypeScript. First, some ground-work revision on records with type
.
// A simple type 'shape'; it usually means 'just these fields', eg. only 'id':
type HasId = { id: number };
// eg.
const x: HasId = { id: 123 }; // Exactly this record
And now composing some of these shapes together:
// 1) "Nested" structure of types; is matches how Elm does it, eg.
// type alias Named r = { r | name: string };
type Named<R> = { name: string };
// These are equivalent:
type alias User = Named<{ id: number }>;
type alias User = { name: String, id: number };
// eg.
const y: User = { id: 123, name: "Thing" };
// 2) "Sideways"/"sibling" composition of types.
// Composing two shapes, using an 'intersection' (the & operator):
type Named = { name: string };
type User = Named & { id: number };
const z: User = { id: 123, name: "Thing" };
And a couple of ways of using them with functions:
// (This is with our 'intersection' version of Named from earlier, but we could
// do the nested Named<R> version as well if that suits us.)
type Named = { name: string };
// 1) Generics, like Elm.
// Pass generics all the way through. This is like saying:
// sayHello : Named r -> String
// or, in other languages that make you explicitly declare generics variables:
// sayHello : forall r. Named r -> String
const sayHello = <R>(thing: Named & R) => "hello " + thing.name;
// or:
function sayHello<R>(thing: Named & R) {
return "hello" + thing.name;
}
// eg.
sayHello({ name: "Hal 9000", sentient: true });
The other way of doing this is using interfaces. Interfaces are less composable+flexible for this kind of case, as you can't use type variables (eg. the <R>
) for either the nested-type-structure or sideways methods shown above, and you can't have anonymous interfaces. You can at least, though, have a less flexible version of 'intersection' with the 'extends' feature.
Mimicking the above:
interface Named {
name: string;
}
interface User extends Named{
id: number;
}
// name, and anything else
interface AtLeastNamed extends Named {
[propName: string]: any; // this indicates that any other property is allowed
}
const sayHello = (thing: AtLeastNamed) => "hello " + thing.name;
sayHello({ name: "Hal 9000", sentient: true });
The saving grace of interfaces do let you have an less-flexible 'intersection' of sorts, using extends
:
interface User extends Named {
id : string;
}
Work in progress
A summary is that purity is 100% enforced in Elm - all side effects must be specified in type annotations & you cannot mutate data that's passed into a function. Typescript cannot guarantee the absence of side effects and has some level of support for checking that you haven't mutated arguements.