Taming Dynamic Data in TypeScript
I really like static types. A lot. When I would otherwise be using JavaScript, I’ve now fully embraced TypeScript.
Fully utilizing static types, with all the safety they provide, can be a bit tricky when dealing with dynamic data — like JSON from an API call. This problem is not unique to TypeScript, but TypeScript does have some fairly unique considerations.
Let’s consider some JSON that we want to parse:
const rawJson = `{
"name": "Alice",
"age": 31
}`
const parsed = JSON.parse(rawJson) //'parsed' is type 'any'
This is how we would do it in JavaScript, too.
Indeed, if you hover over parsed
in your IDE, you’ll see that its type is any
.
This means TypeScript will let us do anything with parsed
without giving us any static type checking.
For example, we might make a typo:
console.log(parsed.nam) //prints 'undefined'
TypeScript doesn’t catch the error; it will happily print out undefined
.
Avoiding any
To get the most from TypeScript, you really should avoid using any
whenever possible.1
It’s hard to trust your static types when you have places in your code that bypass the type system via any
.
In cases where you really don’t know the type (like after parsing some raw JSON), use unknown
, a type-safe counterpart to any
.
For example, we could define a safer parse function like this:
const parseJson = (str: string): unknown => JSON.parse(str)
The body is just a pass-through, but the unknown
return type annotation makes the type much narrower.
Now if we try to access any properties off of our parsed JSON, we’ll get a type error:
const parsed = parseJson(rawJson)
console.log(parsed.nam) //type error: Object is of type 'unknown'.
console.log(parsed.name) //also a type error - we'll come back to this one :)
Super safe, but not very useful yet. That’s OK — it will force us to be explicit about the type, as we’ll see below.
Type Assertions
First, let’s define a type that matches the JSON:
type User = {
name: string
age: number
}
With this, we can now use a type assertion of User
on the parsed value to get our static typing:
const parsed = parseJson(rawJson) as User
console.log(parsed.nam) //type error: Property 'nam' does not exist on type
console.log(parsed.name) //works
This effectively tells TypeScript, “I know something you don’t; trust me here.”
Taking it Further
Type assertions are simple and effective, but there is one problem: TypeScript doesn’t do any validation at runtime to make sure your assertion is correct. If the data is in an unexpected shape, or you declared the type incorrectly, you will likely get errors, but they may occur far from where you initially asserted the type. This can make it hard to track down the exact problem.
It may be feasible to do your own validation for simple objects, but this gets tedious fast, especially as your objects get bigger or have any sort of nesting.
How do Other Languages Handle This?
To fully understand the quandary that we’re in, it’s helpful to look at how other static languages turn dynamic data into typed objects.
Many languages — such as Java, C#, and Go — have type information at runtime that can be accessed via reflection. These languages can use the type information from classes to deserialize JSON into well-typed objects.
Languages like Rust have macros that can automatically generate decoders for a given struct at build-time.
Languages that have neither reflection, nor macros, typically have libraries to manually construct these decoders. Elm is a great example.
TypeScript falls into this latter camp of a language without reflection or macros, so we have to go the manual route.
Manual Decoding
The two major libraries I’ve seen for writing these decoders in TypeScript are io-ts
and runtypes
.
If you come from a functional programming background, you’ll probably like io-ts
.
Otherwise, you may find runtypes
more approachable.
Let’s take a brief look at how to construct decoders in runtypes
:
import { Record, String, Number } from 'runtypes'
const UserRuntype = Record({
name: String,
age: Number
})
That’s it. It’s nearly as easy as declaring a TypeScript type, and it will provide us with methods to validate our data:
import { Record, String, Number } from 'runtypes'
const UserRuntype = Record({
name: String,
age: Number
})
type User = {
name: string
age: number
}
const rawJson = `{
"name": "Alice",
"age": 31
}`
const user = parseJson(rawJson)
const printUser = (user: User) => {
console.log(`User ${user.name} is ${user.age} years old`)
}
if (UserRuntype.guard(user))
printUser(user)
The guard
method used at the end is a type guard for safely checking whether an object conforms to our type.
Within the if
statement, the type is refined to be of type { name: string, age: number }
— essentially the User
type that we defined above.
Seeing Double
You probably noticed that we basically defined the same type twice:
const UserRuntype = Record({
name: String,
age: Number
})
type User = {
name: string
age: number
}
Having to define both a TypeScript type and a corresponding runtype is not ideal.
Luckily, runtypes
is able to derive a TypeScript type from our runtype like this:
import { Record, String, Number, Static } from 'runtypes'
const UserRuntype = Record({
name: String,
age: Number
})
type User = Static<typeof UserRuntype> //equivalent to: type User = { name: string, age: number }
There you have it. You do need to learn the library’s DSL, but at least you don’t have to define the type twice!
Complete Example
Let’s put it all together:
import { Record, String, Number, Static } from 'runtypes'
const parseJson = (str: string): unknown => JSON.parse(str) //expell 'any'
const UserRuntype = Record({ //create a runtype
name: String,
age: Number
})
type User = Static<typeof UserRuntype> //derive a TypeScript type from our runtype
const printUser = (user: User) => {
console.log(`User ${user.name} is ${user.age} years old`)
}
const rawJson = `{
"name": "Alice",
"age": 31
}`
const user = parseJson(rawJson) //the 'user' type is 'unknown'
if (UserRuntype.guard(user))
printUser(user) //'user' is refined by our guard to type 'User'
This Seems Hard!
It may seem easier to just use a dynamic language, like JavaScript, but that just defers possible type errors to runtime. You still need to be aware of the structure of your data. Time that you spend upfront being explicit about that structure with the type system will pay dividends, both in initial development and beyond.
The type assertion approach to typing your dynamic data is low-cost and certainly better than falling back to dynamic typing.2 Then layer on some runtime verification for even more confidence.
Happy typing!
-
Also make sure you enable
noImplicitAny
in yourtsconfig.json
. (Or, better yet, just usestrict
, which will get you this and a bunch of other safer defaults.) Unfortunately, this won’t catch all cases ofany
, such as when data is explicitly annotated withany
(likeJSON.parse
). ↩︎ -
If you decide to just go the type assertion route, make sure you run the code to verify you asserted the type correctly. Better yet, write a test! ↩︎