Skip to content

Instantly share code, notes, and snippets.

@elbow-jason
Last active September 26, 2019 17:15
Show Gist options
  • Save elbow-jason/57df5b7c244cc58caeb134c7031b3c8d to your computer and use it in GitHub Desktop.
Save elbow-jason/57df5b7c244cc58caeb134c7031b3c8d to your computer and use it in GitHub Desktop.
Bad, Better, Best - Loss of type information in Elixir
x = nil
# Q: What is x?
# Q: What type is it?
# Q: What does it represent?

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

Blank on purpose

x = nil
# Q: What is x?
# A: x is nil
# Q: What type is it?
# A: Technically x is an atom, but this atom has special treatment in Elixir/Erlang.
assert is_nil(x) == true
assert is_atom(x) == true
# Q: What does it represent?
# A: It is tempting to say that it represents a missing value.
# While in the context of it's creation nil may represent "a missing value", but outside of the
# context of it's creation nil represents a total loss of type information. Effectively, nil becomes
# a placeholder for any type.
# The following examples focus on nil, but in reality any unexpected type is bad; and some of them
# are much worse than nil.

Collections Are Complex

Collections of values can have complex forms/structures. Getting/Putting/Engineering the data of your application/system into an optimal shape/structure is a difficult problem.

The following examples are going to deal with small structure that represents a user.

defmodule User do
def build(params) do
%{
"name" => params["name"],
"age" => params["age"]
}
end
end
# Q: What is the type of params?
# A: "WHO KNOWS?"
# Q: What is the type of the "name" field? "age"?
# A: "WHO KNOWS?"
# Q: Is the following a user? %{"name" => "Jason", "age" => "21"}
# A: No way to tell.
defmodule User do
def build(params) do
%{
"name" => params["name"],
"age" => params["age"],
"__type__" => "User"
}
end
end
# Q: What is the type of params?
# A: "WHO KNOWS?"
# Q: Is the following a user? %{"name" => "Jason", "age" => "21", "__type__" => "User"}
# A: Maybe
# Q: What are the *intended* types?
# A: "WHO KNOWS?"
# Q: Are the types ever checked?"
# A: "WHO KNOWS?"
# Q: Did the compiler help me at all?
# A: Only with syntax.
# Q: Where do User functions go?
# A: "WHO KNOWS?"
# Q: How can we find the user functions given our user?
# A: Can't without a lookup that returns `User`.
defmodule User do
def build(params) do
%{
"name" => params["name"],
"age" => params["age"],
"__type__" => User
}
end
end
# Q: What is the type of params?
# A: "WHO KNOWS?"
# Is the following a user %{"name" => "Jason", "age" => "21", "__type__" => User}
# A: Maybe
# Q: What are the *intended* types?
# A: "WHO KNOWS?"
# Q: Are the types ever checked?"
# A: "WHO KNOWS?"
# Q: Did the compiler help me at all?
# A: Only with syntax.
# Q: Where do User functions go?
# A: Maybe in the User module
# Q: How can we find the user functions given our user?
# A: The `User` module is on the map as `"__type__"`.
defmodule User do
defstruct name: nil,
age: nil
def build(params) do
%User{
name: params["name"],
age: params["age"],
}
end
end
# Q: What is the type of params?
# A: "WHO KNOWS?"
# Is the following a user %User{"name" => "Jason", "age" => "21"}
# A: Yes
# Q: What are the *intended* types?
# A: "WHO KNOWS?"
# Q: Are the types ever checked?"
# A: "WHO KNOWS?"
# Q: Did the compiler help me at all?
# A: The compiler can now enforce the existance of our User keys for User literals.
# User literal - Will compile
%User{
name: "Mackey",
age: :yes,
}
# bad User literal - Will not compile
%User{
name: "Mackey",
age: :yes,
what_is_this?: "An invalid key!"
}
# Q: Where do User functions go?
# A: In the User module
# Q: How can we find the user functions given our user?
# A: The `User` module is on the map as `:__struct__` field.
defmodule User do
defstruct name: nil,
age: nil
def build(%{"name" => name, "age" => age}) do
%User{
name: name,
age: age,
}
end
end
# Q: What is the type of params?
# A: It's a map!
# Is the following a valid user %User{"name" => "Jason", "age" => "21"}
# A: "WHO KNOWS?"
# Q: What are the *intended* types?
# A: "WHO KNOWS?"
# Q: Are the types ever checked?"
# A: "WHO KNOWS?"
# Q: Did the compiler help me at all?
# A: The compiler can now enforce the existance of our User keys for User literals.
# Q: Where do User functions go?
# A: In the User module
# Q: How can we find the user functions given our user?
# A: The `User` module is on the map as `:__struct__` field.
defmodule User do
defstruct name: nil,
age: nil
def build(params) do
%User{
name: parse_name(params),
age: parse_name(params),
}
end
defp parse_age(%{"age" => age}), do: age
defp parse_age(_), do: nil
defp parse_name(%{"name" => name}), do: name
defp parse_name(_), do: nil
end
# Q: What is the type of params?
# A: It's a map!
# Is the following a valid User %User{"name" => "Jason", "age" => "21"}
# A: "WHO KNOWS?"
# Q: What are the *intended* types?
# A: `:name` is supposed to be a string and `:age` supposed to be an integer
# Q: Are the types ever checked?"
# A: Yes!
# Q: Did the compiler help me at all?
# A: The compiler can now enforce the existance of our User keys for User literals.
# Q: Where do User functions go?
# A: In the User module
# Q: How can we find the user functions given our user?
# A: The `User` module is on the map as `:__struct__` field.
# Issues:
# Is age a less than zero? `nil`? a string? a stringy-map?
defmodule User do
  defstruct name: nil,
            age: nil
            
  def build(params) do
    %User{
      name: parse_name(params),
      age: parse_name(params),
    }
  end
  
  defp parse_age(%{"age" => age}), do: age
  defp parse_age(_), do: nil
  
  defp parse_name(%{"name" => name}), do: name
  defp parse_name(_), do: nil
end

defmodule User do
  defstruct name: nil,
            age: nil
            
  def build(%{"name" => name, "age" => age}) do
    %User{
      name: name,
      age: age,
    }
  end
end

WHICH IS BETTER??

A: The bottom one is better, but only SLIGHTLY. And they are both bad.

A CONTRIVED SEMI-PASSIVE-AGGRESSIVE CONVERSATION BETWEEN JOE HARROW AND JASON GOLDBERGER

Imagine a conversation where joe is Joe Harrow and me is Jason Goldberger.

--

joe: Why is the bottom one better?

me: It crashes when the caller gives a bad params map or anything else.

--

joe: Why is that good? I don't want my code to crash.

me: Crashing here is better than continuing with bad inputs.

--

joe: I don't want my code to crash. Why is this better?

me: Ask me for 2 inputs.

joe: Why? This is stupid.

me: Just do it.

--

joe: Please give me two inputs.

me: 2 and 10

joe: Ok.. 2 and 10.

me: Give me a number between the two numbers.

joe: 6.

me: Ok. Ask me for two inputs again.

joe: Please give me two inputs.

me: 2, 10, 25, and "beef"

joe: That makes no sense.

me: Are you telling me I have provided you with unacceptable input?

joe: I said 2 inputs, but you gave me 3 numbers and "beef".

me: I know what I gave you. Do you accept my 2 inputs?

joe: But it was 3 numbers and "beef".

me: Do you wish to continue with the 2 inputs I gave you?

joe: But it was 3 numbers and "beef".

me: I gave you 2 inputs: 2, 10, 25, and "beef".

joe: That makes no sense. Are you messing with me?

me: Only a little... What you are experiencing is an exceptional input. AN EXCEPTION.

--

me: Given exceptional input should a program continue?

joe: Probably not.

me: No. It should not.

--

joe: But that does not differentiate the two examples above.

joe: Why is the "whole-map-pattern-matching-bottom-example" better than the "get-each-key-or-nil" example?

me: Ah.

me: Ask me for two numbers.

joe: Please give me two numbers.

me: 10 and "beef".

joe: Dude! "beef" is not a number.

me: Joe, are you checking types in the example above?

joe: No...

me: I have given you 2 numbers as you requested. What are the inputs?

joe: 10 and "beef"... (but "beef" is not a number)

me: Write them down on this sheet of paper. Now put the paper into this envelope.

me: Later on we are going to open an envelope. It might be yours it might be another. We will read the contents and then pick a number between the two numbers.

joe: But...

me: but, what?

joe: That might not work.

me: Why would you say that?

joe: it had "beef"...

me: The function we are calling is open_the_envelope_and_pick_number_between_the_two_numbers.

me: Why would that not work?

joe: "beef" is not a number.

me: So.. you can't pick a number between "beef" and some other number?

joe: Dude. I know you are messing with me.

me: Joe, if "beef" is not a valid number why would you put it in the envelope?

me: Why not check the type of your inputs BEFORE you put them into the envelope?

me: Can you imagine what it is like for a poor little function to get envelopes all day and the values inside make no sense?

me: Do you even care about the little functions, Joe?

joe: I CARE!

--

Epilogue

After this conversation, joe became an advocate for the care and good treatment of functions. He improved his type checking and went on to become a master codeologist.

It is said that me is still out there complaining about loss of type specificity to this day...

defmodule User do
defstruct name: nil,
age: nil
def build(params) do
%User{
name: parse_name(params),
age: parse_name(params),
}
end
defp parse_age(%{"age" => age}) when is_integer(age) and age >= 0, do: age
defp parse_name(%{"name" => name}) when is_binary(name) do
if String.printable?(name) do
name
else
raise "name must be a string"
end
end
end
# This example is very safe for runtime types, but it will crash on bad inputs.
# There is a less-crashy way.
defmodule User do
defstruct name: nil,
age: nil
def build(params) do
with(
{:ok, name} <- parse_name(params),
{:ok, age} <- parse_age(params),
) do
user = %User{
name: name,
age: age,
}
{:ok, user}
else
{:error, _} = err -> err
end
end
defp parse_age(%{"age" => age}) when is_integer(age) and age >= 0, do: {:ok, age}
defp parse_age(%{"age" => _}), do: {:error, {:age, "must be a non-negative integer"}}
defp parse_age(_), do: {:error, {:age, "is a required key"}}
defp parse_name(%{"name" => name}) when is_binary(name) do
if String.printable?(name) do
{:ok, name}
else
{:error, {:name, "must be a string"}}
end
end
defp parse_name(_) do
{:error, {:age, "is a required key"}}
end
end
# This example gives us type checking, and a crash-free way to parse our user.
# However we must (ideally) ensure our error returns are homogenous and only one error can be reported at a time.
# Very procedural
defmodule User do
use Ecto.Schema
alias Ecto.Changeset
embedded_schema do
field(:name, :string)
field(:age, :integer)
end
def changeset(%User{} = user, params) do
user
|> Changeset.cast(params, [:name, :age])
|> Changeset.validate_required([:name, :age])
# maybe not the actual call, but you get the idea
|> Changeset.validate_number(:age, gte: 0)
end
end
# This example is best for runtime.
# Benefits:
# type checking
# multi-error reporting
# delarative types
# idiomative
# 1/2 the lines of code
# Drawbacks
# boring
# Benefits
# boring

worst

user[:name]

user can be a map, keyword list, or nil. crashes on other types.

2nd worst

user["name"]

Might give value of "name". Might give nil. Might raise an exception. The user could be map or nil.

iex(2)> u = %URI{}
%URI{
  authority: nil,
  fragment: nil,
  host: nil,
  path: nil,
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}
iex(3)> u["bleep"]
** (UndefinedFunctionError) function URI.fetch/2 is undefined (URI does not implement the Access behaviour)
    (elixir) URI.fetch(%URI{authority: nil, fragment: nil, host: nil, path: nil, port: nil, query: nil, scheme: nil, userinfo: nil}, "bleep")
    (elixir) lib/access.ex:267: Access.get/3

bad

Map.get(user, :name)

could return nil. user could be any map. wont crash for map. will crash for any other type.

meh

user.name

could be any map. will crash on key miss. no compiler help.

meh

Map.fetch!(user, :name)

Same as user.name.

better

Map.get(user, :name, "Anonymous")

If a sane default can be provided Map.get/3 is "okay" to use. Does not accidentally generate nil. No compile-time help.

best

%User{name: name} = user

user must be a User or crash. name must be on User struct at compile-time.

The expected type of user is apparent.

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