Typescript team has explicitly stated that they don’t intend to extend typescript’s static type checking to the runtime.
Typescript Design Goals lists the following in the section on Non-goals:
Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.
While on one hand this design goal is advantageous in that the output generated by typescript is very close to what hand written javascript would have looked like. And the runtime cost of typescript remains close to zero, therefore adoption of typescript does not alter performance characteristics of an application.
However, this also implies that for cases when static typing cannot help us we need to separately write validators using a validation library (eg. Joi) which has to be kept in sync with the typescript types.
This post outlines an approach to eliminate this redundancy using the io-ts library by Giulio Canti. This approach has also been adopted by some other libraries like MobX State Tree and Runtypes.
Runtime Boundaries
Runtime boundaries are cases where some untyped data from sources only available at runtime enters into our application. Examples include reading a JSON or CSV file from disk, getting a JSON payload from an HTTP request in a controller etc.
In these scenarios, the data source is not available when the compiler is compiling the code, therefore the compiler can not guarantee the type correctness of the data structures we consume. For such a guarantee to exist, it would be needed to write a validator that validates the incoming data structures at runtime.
It may be argued that if we have end-to-end control over our application’s lifecycle then we could have ensured that any data source we are reading from at runtime would have ostensibly been generated by type safe code and therefore runtime validations would be unnecessary, however in many practical scenarios this is not the case. We need to fetch data from external APIs, user edited configuration files etc. which are outside the realm of the types defined in our application.
Similar concerns are also applicable when the whole of the codebase is not yet using a type checker or we are writing a library which we would like to be usable by both javascript and typescript users. However, if the user is not using typescript (or can’t be trusted to not use escape hatches like any
or @ts-ignore
), we can no longer make assumptions about the interface contracts and when these assumptions fail the error messages can be quite obtuse at times.
So it is better to have strict validations at the boundaries when untyped data is coerced to typed data structures, so that the rest of our code can make strong assumptions about the type guarantees and can take advantage of a static type checker.
Runtime Types
io-ts introduces the concept of a runtime type (an instance of the Type class) which represents a runtime validator for a typescript (static) type.
A value of type
Type<A, O, I>
(called “runtime type”) is the runtime representation of the static typeA
.
In practice, most of the times we would not be instantiating or subclassing Type class directly. Rather we would use many of the Type instances exposed by the library (or helper libraries like io-ts-types) and take advantage of composability of type instances.
For example, io-ts exposes a string type, which we can use as follows:
1 2 3 |
const Name = t.string; const validationResult = Name.decode("lorefnon"); validationResult.isRight(); // ==> true |
We can combine these types through combinators to build composite types which represent entities like domain models, request payloads etc. in our applications. For example:
1 2 3 4 |
const Person = t.type({ name: t.string, age: t.number }); |
Person is an instance of InterfaceType class:
1 2 3 4 |
interface Person { name: string; age: number; } |
had we written the same thing as a static type.
The advantage of using io-ts to define the runtime type is that we can validate the type at runtime, and we can also extract the corresponding static type, so we don’t have to define it twice.
1 2 3 4 5 6 7 8 9 10 11 |
type IPerson = t.TypeOf<typeof Person>; const requestPayload = { name: "Lorefnon", age: "30" }; const validationResult = Person.decode(requestPayload); // Throws if the value is not typecompliant // No-op otherwise ThrowReporter.report(validationResult); const person: IPerson = validationResult.value; |
This will now fail with a validation error:
This can be extended to compositions:
Encoding/Decoding
We noted above that we used the decode method when performing a type validation at runtime.
When dealing with untyped data obtained at runtime boundaries we often also have to deal with serialization/deserialization and converting the raw data (structure) to a hierarchy of instances of typescript classes relevant to business logic.
To handle these concerns, the Type class implements of Encoder and Decoder interfaces.
A runtime type can
- decode inputs of type
I
(throughdecode
)- encode outputs of type
O
(throughencode
)- be used as a custom type guard (through
is
)
In a wide number of cases (eg. the examples above) our output type will be same as the static type which the runtime type represents.
An example where having a separate output type can be useful can be seen in the DateFromISOStringType where the output type can be an ISO formatted String.
So we can, for instance, use this runtime type to decode request payloads where the date string is represented as an ISO formatted string. While decoding, this will be converted to a date instance. And when encoding output (eg. when sending response back to client) this will be serialized to a string as per the ISO 8601 extended format.
In this case, this circumvents the limitation of a date type not being supported by json but in general, this strategy can be extended to do things like auto instantiation of Model classes in our application from request payload.
Integrations
A react integration library has been made available by the author of io-ts. This is similar to how prop-types in react used to work, but takes advantage of the more sophisticated runtime type system of io-ts.
Given the simplicity of the core library and flexibility of error reports, it is also quite easy to integrate the reporters in other contexts.
Example : Express middleware
We had previously discussed the Throw Reporter for error handling.
In web applications, we would typically want to reject requests with an appropriate HTTP code on validation failures. This is easily achieved by wrapping the io-ts PathReporter as a middleware. The example below illustrates an express middleware, but besides the middleware signature there is nothing express specific here.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
const validationMiddleware = (reqType: t.Type<any>) => (req: express.Request, res: express.Response, next: () => void) => { const result = reqType.decode(req); const report = PathReporter.report(result); if (result.isRight()) { next() } else { res .status(406) .json({ success: false, errors: report }); } } app.get( '/:requestId', validationMiddleware(t.type({ params: t.type({ // Ensure that requestId is 4 digit long requestId: t.refinement(t.string, (n) => n.length === 4) }), query: t.type({ // Ensure that requestType query parameter // is either order or dispatch requestType: t.union([ t.literal("order"), t.literal("dispatch") ]) }) })), (req, res) => res.json({ success: true }) ); |
Limitations
The author of io-ts has highlighted some corner cases and workarounds for them in the README.
These are typically situations where typescript compiler’s inference support is not good enough (yet) eg. recursively defined types. The solution is basically to write helper types manually.
Comparision with other solutions
Dart/JS++
Languages like Dart and JS++ have runtime type systems. While both are very interesting, and in my opinion superior type systems (esp. given that Dart’s type system is now strict and sound, unlike Typescript which considers neither of these to be a design goal), they are not practical solutions for many of the cases I have to deal with.
In case of JS++ the community adoption is simply too small (as of yet) and in case of Dart there is a significant interop overhead and dart bindings for many libraries are not available yet. Adopting Dart almost implies adopting an another ecosystem.
ts-runtime
A very interesting attempt has been made by Fabian Pirklbauer in the ts-runtime project to extend typescript’s typesystem to runtime through AST transformations. This is a fairly complex and ambitious endeavour and this is reflected in the numerous corner cases that author has highlighted in the documentation. Even if this were production ready (which it is currently not) I’d be sceptical of adopting this unless this were to be officially supported by typescript team.
I don’t find the complexity (of extending the entire type system transparently to runtime) warranted because typically we would want these validations specifically at runtime boundaries and for the rest of the code static type checking would naturally suffice.
typescript-is
Wouter van Heeswijk’s typescript-is takes the opposite approach to io-ts and generates runtime types from static types. It is philosophically similar to ts-runtime but limits itself in scope by handling primitives and interfaces.
Type guards are generated using typescript transformers. This is an interesting approach, and while it requires some configuration during the build step it is quite simple to use.
Unlike io-ts you give up control over the runtime type, so things like custom encoding/decoding logic etc. have to be handled at a separate layer. I find that to be an acceptable compromise in most scenarios.
Also, the runtime overhead of this library is much less compared other runtime type implementations here, because it compiles down to a simple chain of if-else statements:
Taking an example from README:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { is } from 'typescript-is'; interface MyInterface { someObject: string; without: string; } const foreignObject: any = { someObject: 'obtained from the wild', without: 'type safety' }; if (is<MyInterface>(foreignObject)) { // Call expression returns true const someObject = foreignObject.someObject; // type: string const without = foreignObject.without; // type: string } |
The if condition in the snippet above compiles down to:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
if ( (object => { return typeof object === "object" && object !== null && !Array.isArray(object) && ("someObject" in object && typeof object["someObject"] === "string") && ("without" in object && typeof object["without"] === "string"); })(foreignObject) ) { // Call expression returns true const someObject = foreignObject.someObject; // type: string const without = foreignObject.without; // type: string } |
The cost is obviously that the generated code is bloated with repetitive assertion logic. The project may benefit from some facility to extract the assertion logic into reusable validators.
typescript-json-schema
This project takes a somewhat different route and generates JSON schema from typescript sources. The idea of encoding validation logic as json has never appealed to me, but if are working with other systems that already have good support JSON schema (eg. MongoDB) then this may be a good choice.
However, io-ts is strictly more powerful than validating against a generated JSON schema because we have much more control over the runtime types.
Runtypes
Tom Crockett’s runtypes project is very similar to io-ts in that it uses the same concept of Runtime Types. However it does not deal with encoding/decoding values and hence the resultant API is somewhat simpler than io-ts.
However, there are some known issues around handling of mapped types which have already been addressed in io-ts.