Skip to content

Instantly share code, notes, and snippets.

@mbylstra
Last active November 11, 2021 19:38
Show Gist options
  • Save mbylstra/25bb8ac8263edb687f670e222c0e1c7a to your computer and use it in GitHub Desktop.
Save mbylstra/25bb8ac8263edb687f670e222c0e1c7a to your computer and use it in GitHub Desktop.

Elm and Typescript: comparison of type checking features

Each of these examples assume the usage of --strict mode in Typescript

Enums

Elm

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.

Typescript

Can be implemented with either:

The enum syntax

https://www.typescriptlang.org/docs/handbook/enums.html

enum Size {
    Small,
    Medium,
    Large,
}

const size = Size.Medium;

union types + string literal types

https://www.typescriptlang.org/docs/handbook/advanced-types.html

type Size = "small" | "medium" | "large"

const size = "medium"

Tagged Unions / Discriminated Unions / Custom Types / ADTs / Sum Types

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.

Elm

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"

Typescript

Called "Discriminated Union Types"

type User = Regular | Visitor | Anonymous | Autogenerated;

interface Anonymouse {
    kind: "regular";
}

interface Regular {
    kind: "regular";
    name: string;
}

interface Visitor {
    kind: "visitor";
    name: string;
}

interface Autogenerated {
    kind: "visitor";
    name: number;
}

const user = { kind: “visitor”, name: “HAL 9000 }

Exhaustiveness checking of enums and union types

Both the Elm and and Typescript compilers can check whether you've handled every possible case of an enum or union type.

Elm

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"

Typescript

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 ;)

"Untagged" Union Types

Sometimes you might want a type that can be one of a set of primitive types, such as a number or a string.

Elm

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

Typescript

type Height = number | string;

height = 150;

//or

height = "150"

Records / Product Types / Interfaces / Objects with fixed fields

Elm

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 Record =
    { 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

Typescript

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!

Extensible Records / Extending Interfaces

Elm

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 | 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 }

Typescript

interface Named {
    name: string;
    [propName: string]: any; // this indicates that any other property is allowed
}

You can reuse other interfaces with the extends keyword:

interface User extends Named {
    id : string;
}

Arrays and Linked Lists

Elm

Linked List (slightly off topic: Elm also has Arrays which are indexable)

toppings : List String
toppings = [ "pineapple", "mozarella", "tomato" ]

Typescript

Array

const toppings:string[] = [ "pineapple", "mozarella", "tomato" ]

// or

const toppings:Array<string> = [ "pineapple", "mozarella", "tomato" ]

Type Variables / Generics

Elm

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)

Typescript

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    
}

Custom Types or Discriminated Union Types with type variables

Elm

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

Typescript

What the Elm examples would like like in Typescript

?????

strict null checks and the Maybe type

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.

Elm

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.

Typescript

Similarly, Typescript will forbid assigning a value to null unless you have explicitly allowed it in the type definition. Because typescript

const x: number;
x = 1; // this is good
x = null;  // compiler error

const x: number | null;  // use a union type to allow either a number or null

x = 1; // this is good
x = null; // 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".

Functionality Purity

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.

@damncabbage
Copy link

Custom Types or Discriminated Union Types with type variables

Elm

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

Typescript

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.)

@damncabbage
Copy link

damncabbage commented Apr 9, 2019

Extensible Records / Extending Interfaces

Elm

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 }

Typescript

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;
}

@damncabbage
Copy link

This probably needs a quick section on type and interface. I had some in that last comment:

… 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

For your own info:

  • The long tiring version if you're already familiar with type and interface: https://medium.com/@martin_hotell/interface-vs-type-alias-in-typescript-2-7-2a8f1777af4c

  • The short version if you're new to this and don't care about history:

    • 'type' gets to potentially be a union, or simple values like numbers.
    • 'type' is declared all-at-once, like with Elm type declarations.
    • 'type' is arguably more flexible in a bunch of ways.
    • 'interface' only ever describes a record.
    • 'interface' is can be used multiple times to "build up" a type, eg.
        interface A { id: number; }
        interface A { name: string; }
        const foo: A = {id: 123, name: 'wtf'};
    • 'interface' is "idiomatic TypeScript" for some reason and I can't figure out why, unless "idiomatic" means "familiar to C# users".

okay, rant over

@damncabbage
Copy link

damncabbage commented Apr 9, 2019

I forked and made some edits, and also switched to undefined instead of null for the "null" section, as you kinda have to go out of your way to get a "null" (which is basically Elm's Unit or ()) in JavaScript.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment