Type systems
will make you a better
JavaScript programmer
by Jared Forsyth
Hi, I'm Jared Forsyth, and I'll be talking about how Type Systems will make
you a better JavaScript Programmer.
I work at Khan Academy, and we're working to create a free, world-class
education for anyone, anywhere!
I work on the mobile team, which means that in addition to JavaScript, I write
Java, Objective C, and Swift! Which are all compiled languages that have much
more developed type checking than JavaScript.
So I've seen both sides -- the
flexibility that JavaScript provides, and the power and security that working
in one of these compiled langauges gives you.
And I think that adding a powerful type system to your JavaScript brings a ton of benefits.
Cool things I like
structured editors
algebraic effects
continuous integration
continuations
algebraic data types
react native
reason/ocaml
editor tooling
Why you want more type errors
How to get more type errors
Thinking with types
...beyond
So what's this about?
First I'll give a brief overview of javascript's type system, and how it
doesn't give us nearly enough type errors.
Then I'll go into different ways we can get more type errors :D e.g. detect
the errors that are in our code.
Then we'll talk about how thinking with types will improve your code, make
your coworkers happier and improve your peace of mind.
Finally I'm going to talk about how having a powerful type system changes the
way that you code, to make it safer, more readable, and more maintainable.
buut then I'll go into what lies beyond hahaha
Before I start though - how many of you have used types in your javascript?
(if a lot, then I'll say "here's a way to convince your co-workers to get on
board")
How many of you have used a statically type-checked language?
How many of you hated it?
More type errors!
You've probably been thinking that what you want is fewer errors, but in fact!
What you want is more.
Let's define some terms here first.
What is a type?
a group of things
that can be used interchangeably
In very broad terms, you can think of a type as a group of things that
can be used interchangeably.
What is a type?
numbers (a * b, x - y
)
strings
things that have a name
attribute
Numbers can all be validly added together, subtracted, and
multiplied. Strings can be split, joined, and displayed to the screen.
And we could also talk about "thinks that have a name
attribute, as being a
type.
JavaScript has a type system!
typeof x
number
string
boolean
undefined
function
symbol
object
What are type errors?
when you try to use a thing in a context where it doesn't work.
So a type error, for my purposes, is "when you try to use a thing in a
context where it doesn't work".
And that can mean different things to different language runtimes.
For example, if you pass a number in to a place that expects something with a name
attribute, that's wrong! So I'd consider that a type error, but JS doesn't. So
type errors depend on the context, language runtime, etc.
JavaScript has type errors!
but not nearly as many as one would want.
_ is not a function
cannot read property '_' of null/undefined
JavaScript has very few kinds of type errors; they're triggered when you
try to call something that's not a function, and when you try to get an
attribute of null or undefined.
And this limited vocabulary for expressing errors has two unfortunate
outcomes.
Either you get weird type errors late
var x = 10
var y = x.parent
return y.name
In this trivial example, the actual bug is relatively close to the
exception that JavaScript gives us. But all to often in real-world projects,
the place where JavaScript figures out something has gone wrong is far removed
from the actual source of the error.
...or no errors at all
function doSomething (m ) {
if (m.count > 2 ) {
return "large"
} else {
return "small"
}
}
doSomething(5 )
Even more insidious is when JavaScript doesn't throw an error at all,
because it tries its best to figure out what you meant and manages to avoid
anything it considers a type error.
These are frequently even harder to debug, because you don't have an
exception
stack trace to get you started. You just have to pause in the
middle of a running session and try to figure out how your data got so weird.
JavaScript tries to avoid errors
2 /'' === Infinity
2 + {} === '2[object Object]'
2 + 'phone' -> NaN
alert(1 , 2 , 3 , 4 , 5 )
but it backfires
So what does JavaScript do? It tries to figure out what you meant,
giving you the benefit of the doubt that you probably didn't write a bug. This
ends up backfiring big time, because it makes it much harder to diagnose
problems.
How to get more type errors
linters
custom runtime checking
static type checking
These first two are pretty commonly used, but I'm going to make the case that
static type checking is really the best of both worlds.
TODO animate in?
Linters
you might be thinking "Linters? They don't have anything to do with
types". But in fact they know about 2 types: declared and "not declared"
Umm maybe cut this section?
Linters know about 2 types
function doSomething (argument ) {
return brgment + 1
}
And using a variable that's never declared in non-strict javascript is a
disaster waiting to happen. Even in "strict mode", you won't know about the
error until runtime when the code gets executed.
TODO animate between
Linters
+ runs ahead of time
+ very little work
- very rudimentary
Custom runtime checking
Sometimes useful, frequently annoying.
greet("hello" , ["June" ], 10 )
greet("hello" , ["June" , "July" ], 10 )
greet()
greet("hello" , "June" )
greet(1 , 2 , 3 , 4 , 5 , 6 )
Say you have a function greet
, which takes three arguments
Now, what are situations in which a function would be called with incorrect
arguments? Hopefully not immediately on the day you write it (although that
does happen). But over the lifetime of a project, things get refactored,
variables get added, removed, reused. And suddently it takes a lot of effort
during a refactor to make sure you're just calling functions with the correct
arguments!
Custom runtime checking
Sometimes useful, frequently annoying.
function greet (greeting, months, age ) {
if (arguments .length !== 3 )
throw new Error ('must be called with 3 arguments' )
}
Custom runtime checking
Sometimes useful, frequently annoying.
function greet (greeting, months, age ) {
if (arguments .length !== 3 )
throw new Error ('must be called with 3 arguments' )
if (typeof greeting !== 'string' )
throw new Error ('greeting must be a string' )
if (!Array .isArray(months))
throw new Error ('months must be an array' )
if (typeof age !== 'number' )
throw new Error ('age must be a number' )
}
If you're using runtime type checks to do things that a type checker
would do for you, you're wasting a ton of time.
This is defensive programming, right? And if you're super into this there
are libraries that will check schemas at runtime to take away some of the
boilerplate.
Custom runtime checking
+ very powerful
- lots of extra boilerplaty code
- only at runtime
React propTypes
Runtime type checking, but less annoying.
const MyThing = React.createClass({
propTypes: {
greeting: PropTypes.string ,
months: PropTypes.arrayOf(PropTypes.string ),
person: PropTypes.shape({
name: PropTypes.string ,
})
},
})
With PropTypes, we have runtime type checking, and it's been pretty
streamlined.
BUT
only for react components, not all functions
also runtime-only, although the react-eslint plugin will check your proptypes
definition against your props usage & make sure that at least all of the props
you use are listed there.
React propTypes
+ not too much extra code
+ free documentation
- only at runtime
- only for React Components
documentation, but it might be wrong b/c you haven't updated the prop
types, and maybe you haven't rendered the thing in that configuration
recently.
Static type checking
function sayHello (name: string ): string {
return "Hello " + name
}
Static type checking
function sayHello (name: string ): string {
return "Hello " + name
}
const age: number = 10
const notherAge = 10
Static type checking
function sayHello (name: string ): string {
return "Hello " + name
}
const age: number = 10
const notherAge = 10
const greeting = sayHello("React" )
Static type checking
function sayHello (name ) {
return "Hello " + name
}
const age: number = 10
const notherAge = 10
const greeting = sayHello("React" )
Static type checking
vs custom runtime checking
function greet (greeting, months, age ) {
if (arguments .length !== 3 )
throw new Error ('must be called with 3 arguments' )
if (typeof greeting !== 'string' )
throw new Error ('greeting must be a string' )
if (!Array .isArray(months))
throw new Error ('months must be an array' )
if (typeof age !== 'number' )
throw new Error ('age must be a number' )
}
function greet (greeting: string,
months: Array<string>,
age: number ) {
}
Static type checking
vs React propTypes
class MyThing extends Component {
static propTypes = {
first: PropTypes.number,
second: PropTypes.arrayOf(PropTypes.string ),
third: PropTypes.shape({
name: PropTypes.string ,
})
}
}
class Component extends MyThing {
props: {
first : number,
second : Array <string>,
third : {
name : string,
}
}
}
Static type checking
+ runs ahead of time
+ not much boilerplate
+ applies to all fns, variables, etc.
+ free documentation, never stale
Getting more type errors in JS
Linter
Custom
PropTypes
Flow
When do you know?
now
runtime
runtime
now
How easy
😄
🚫
😊
🙂
Where can it be used?
🙂
😄
🚫
😄
How helpful
🚫
😊
🙂
😄
Readability
🚫
🙂
😄
I like tables, because they allow me to show, unequivocally, that the
thing I like is better than other things :D
Reactions to Flow @ KA
😄 when adding flowtypes to code, it finds bugs!
😄 feel much safer making changes as a team
😄 code is self-documenting!
👎 editor integration is confusing
👎 new syntax + esnext can be overwhelming
At Khan Academy, we've recently started using Flow in our production
JavaScript, and on the whole we've been very happy with the results.
Thinking with types
So I've just gone over the ways we can get more informative type errors,
which will help us
track down bugs to their actual source
document our code & understand how functions are to be used
refactor with confidence
But now I want to talk about how it will change the way you program.
Thinking with types
clever code
implicit invariants
implicit state machines
write down data types first
One of the things I've run into when adding flow to an existing project is
that there are some functions where it's really hard to come up with a type
that will satisfy flow
. Nearly every time, I think about it a little &
realize that the function is being too clever.
Clever code
There is a ton of valid javascript that flow would reject; so if we're
restricting ourselves, what are we gaining?
Code that flow can type is also code that other people will be able to
understand better.
Everyone knows that debugging is twice as hard as writing a program in the first place.
So if you're as clever as you can be when you write it, how will you ever debug it?
Clever code
clever
props['on' + (fastClick ? 'MouseDown' : 'Click' )] = myFn
unclever
if (fastClick) {
props.onMouseDown = myFn
} else {
props.onClick = myFn
}
Clever code
messing w/ arguments
function doAllTheThings (first, second, third ) {
if (third === undefined ) {
third = second
first = {options : first}
}
}
"flag" arguments
function doAllTheThings (dataIsBoolean, data ) {
if (dataIsBoolean) {
} else {
}
}
If it's hard to type check, it's probably hard to understand
Implicit invariants
state: {
loading : boolean,
error : ?string,
data : ?SomeObject,
}
render() {
if (this .state.loading) return ...
if (this .state.error || !this .state.data) return ...
return <button onClick={this .onClick}>
Click me!
</button >
}
TODO explain optionals
also explain how I'm using react, but it applies to other stuff.
frequently we have React components that are really representing little
state machines. Here's an example that might look familiar -- we have a
component that fetches some data, and so it starts out loading, and it will
either display an error on failure or display the data in some wonderful way.
Implicit invariants
state: { loading : boolean, error : ?string, data : ?SomeObject }
onClick = () => {
alert(this .state.data.name)
}
render() {
if (this .state.loading) return ...
if (this .state.error || !this .state.data) return ...
return <button onClick={this .onClick}>
Click me!
</button >
}
Implicit invariants
state: { loading : boolean, error : ?string, data : ?SomeObject }
onClick = () => {
if (!this .state.data)
throw new Error ('lol this will never happen' )
alert(this .state.data.name)
}
render() {
if (this .state.loading) return ...
if (this .state.error || !this .state.data) return ...
return <button onClick={this .onClick}>
Click me!
</button >
}
Here's one way to fix it! If you find yourself doing this, it's a huge
warning sign.
"Of course it's not null" you think, "this callback function couldn't have
been triggered if data wasn't present!"
so what do we do here? How can we get flow off our backs by proving to
it that, if the button w/ the onClick handler was rendered, then
this.state.data
is definitely true?
Turns out it's not that hard
What we need to do is ensure that onClick
literally can't be called unless
data is populated. Currently it happens that we're only calling it when data
is populated, but that's not enforced by the structure of the code.
Implicit invariants
render() {
if (this .state.loading) return ...
if (this .state.error || !this .state.data) return ...
return <TheContents data={this .state.data} />
}
}
class TheContents extends Component {
props: {data : SomeObject}
onClick = () => {
alert(this .props.data.name)
}
render() {
return <button onClick ={this.onClick} > Click me!</button >
}
Make a child component that gets this.state.data
as props only when it's
present , and it will be clearer to flow and to readers .
Also: this doesn't just apply to state. You could have an optional
thing come in as props, and if you want a scope in which you know that it
will always be non-null, make a child component!
The point I want to drive home here is: If you didn't have flow watching
your back, yes it would save you the trouble of adding the extra layer, but
your code would be more complicated & less readable as a result. You would
have to keep more things in your head ("is X initialized yet?") as a result,
and you'd have more bugs.
Example: KA mobile app
Loading
Answering
Finished
The naive state representation
type State = {
loading : boolean,
problems : ?Array <Problem>,
answers : ?Array <Answer>,
currentProblem : number,
pointsData : ?PointsData,
}
So here's the naive way of representing the state involved - we just
think of all the information we need to track and we throw it on there.
It's common to just start with an empty object and throw things on as
you need them.
Here's a hypothetical object that would manage the state of a quiz that a
learner is taking on Khan Academy. There are 3 phases of this quiz; first
there's a loading screen while we fetch the questions. Then they're taking
the quiz, going through each question one by one.
Then when they finish there's a success screen, telling them how many points
they got.
The naive state representation
state = {
loading : true ,
problems : null ,
answers : null ,
currentProblem : 0 ,
pointsData : null ,
}
state = {
loading : false ,
problems : [...some array],
answers : [...some array],
currentProblem : 3 ,
pointsData : null ,
}
state = {
loading : false ,
problems: [...some array],
answers : [...some array],
currentProblem : 0 ,
pointsData : {some data},
}
And here's some example data for the different screens I showed.
The problem with this representation is that there are all sorts of illegal
states that will still type check fine.
The naive state representation
Allows illegal states
type State = {
loading : boolean,
problems : ?Array <Problem>,
answers : ?Array <Answer>,
currentProblem : number,
pointsData : ?PointsData,
}
state = {
loading : false ,
problems : [...some array],
answers : null ,
currentProblem: 0 ,
pointsData : null ,
}
Based on the type definition, this is a valid state. But as the
programmer writing the code, you think "of course when problems is present,
answers will also be present -- they go together". You might know that, but
flow doesn't know that, and the next developer who comes along also won't
necessarily know that.
Representing the state machine
Make illegal states invalid
enum State {
case Loading
case Answering(
problems: Array <Problem>,
answers : Array <Answer>,
currentProblem : int
)
case Finished(PointsData)
}
If you were lucky enough to be using an ML-family language like Swift or Rust
or Ocaml, you'd be able to represent the State like this:
Representing the state machine
Make illegal states invalid
type State = {
screen : 'loading' ,
} | {
screen : 'answering' ,
problems : Array <Problem>,
answers : Array <Answer>,
currentProblem : number,
} | {
screen : 'finished' ,
pointsData : PointsData
}
But here in javascript land we've got something similar - a tagged union.
So you can see that the invariant that we previously had to hold inside
our head "whenever problems is present, answers will be also" is now encoded
in the type, and therefore enforced by flow, and more understandable to
maintainers later!
This is a much better representation, because it makes it clear what
things are going to be optional at what times. Without these types, it might
be clear to you as the author that "when you have a questions array you'll
also have an answers array and you won't have earnedBadgeData", but it
certainly won't be clear to a coworker, or to you a month from now.
If you're making a ton of things optional, you're probably trying to
represent a state machine poorly.
Write down data types first
Once I got into the mindset of thinking about the types I'm working with
in an explicit fashion, I would frequently find myself planning out, first and
foremost, what the data types look like.
What shape will the data have?
What are the possible states for this component to be in
What kind of signature do I want this function to have?
Don't get me wrong: I'm not advocating for java-style class hierarchies
and UML diagrams -- just thinking about what the data will look lke.
Write down data types first
type Post = {
title : string,
createDate : Date ,
contents : string,
authors : Array <{
name : string,
location : string,
numberOfPosts : number,
}>,
}
Thinking with types
avoid clever code
make invariants explicit
make state machines explicit
write down data types first
When 80% typed isn't enough
20% unsafe can very well mean 100% of users see bugs.
Now, there are a lot of things I love about flow, and I'm very happy that we
have it in our codebases at work, it's not all sunshine and roses.
When 80% typed isn't enough
Library functions are "any
" by default
import _ from 'underscore'
const criticalFunction = (config: {name : string}): number => {
return _.pick(config, 'name' ).naem
}
When 80% typed isn't enough
You'll use "any
" to just get on with things
const criticalFunction = (config: any ) => {
}
When you're moving fast, you'll toss in an any
to get
flow to be quiet (flow throws an error if any exported function isn't
annotated). This is also super tempting -- and pragmatic -- when you're
migrating a large file over to being typed.
There are 2 problems with this. One: any
types are infectious -- type
inference essentially bails when dealing with a value marked as any
.
The other is that you're now relying on human intervention to clean things up.
By my count, here at Khan Academy, we have 357 javascript files in the webapp
with the '@flow' pragma, and 183 uses of any
, in 84 files - almost a
quarter.
Flow & TypeScript compromise soundness
What if you want more?
Elm
Reason/OCaml
(lots of others too)
This is to just whet your pallet
give you all a teaser about, if you're interested going deeper into the "more
typed" world. I'd definitely recommend Elm and Reason as very interesting
projects to check out.
Elm
👍 solid documentation
👍 friendly error messages
👍 coherent tooling
🚫 very locked down JS interop
🚫 fairly young language / type system
🚫 I can't stand the syntax
Reason/OCaml
👍 very mature language
👍 awesome type system
👍 very pragmatic (mutability)
👍 excellent JS interop
👍 can also compile to native
🚫 horrid documentation
🚫 build tooling still settling
Comparison of the things
TS
Flow
Elm
Reason
IDE integration
😄
😊
🙂
🚫
JS Interop
😄
😄
🚫
😊
Type system
🙂
😊
😄
😄🎉
Maturity
🙂
🙂
😊
😄
Inference
🙂
😊
😄
😄
Documentation
😊
🙂
😊
🚫
Ease of adoption
😊
😄
🙂
🚫
Flow or TypeScript?
Flow
TypeScript
great w/ babel + webpack
owns whole build chain
more powerful type system
better documentation
better inference
better community
easier inc. adoption
more IDE features
Conclusion
JavaScript doesn't give us enough type errors
Static type checking can help!
Working with types will help you think better
You might want a 100% type system instead of 80%
Type systems
will make you a better
JavaScript programmer
by Jared Forsyth
Hi, I'm Jared Forsyth, and I'll be talking about how Type Systems will make
you a better JavaScript Programmer.
I work at Khan Academy, and we're working to create a free, world-class
education for anyone, anywhere!
I work on the mobile team, which means that in addition to JavaScript, I write
Java, Objective C, and Swift! Which are all compiled languages that have much
more developed type checking than JavaScript.
So I've seen both sides -- the
flexibility that JavaScript provides, and the power and security that working
in one of these compiled langauges gives you.
And I think that adding a powerful type system to your JavaScript brings a ton of benefits.