Guaranteeing Runtime Safety with TypeScript
The Power of the TypeScript Compiler
The TypeScript compiler is a powerful tool that helps you understand the data you’re working with. With minimal configuration, it can save you from having to validate everything with tests or manually in a UI.
Libraries often have decent type definitions, and many are written in TypeScript. By adding additional flags, you can turn up the quality of your code:
strict
: Enforces types and includesnoImplicitThis
andnoImplicitAny
.noEmitOnError
: Ensures all emitted code is checked.noImplicitReturns
: Warns when a function doesn’t return a value.noFallthroughCasesInSwitch
: Warns when a switch statement doesn’t handle all cases.
{
"compilerOptions": {
"strict": true,
"noEmitOnError": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Runtime Safety vs. Compile-Time Safety
While TypeScript provides compile-time safety, it’s still possible for runtime errors to occur. Runtime exceptions are a feature of JavaScript and can be caught with try-catch blocks or handled as Promise rejections.
However, we can do better. By using libraries like runtypes and io-ts, we can validate data at runtime and prevent unexpected errors.
Dealing with Unknown Data
When working with external data, it’s essential to validate it to prevent runtime errors. We can use libraries like runtypes to declare interfaces and ensure data conforms to those interfaces.
For example, when fetching data from an API, we can use runtypes to validate the response and prevent errors.
import { createValidator } from 'runtypes';
interface ApiResponse {
id: number;
name: string;
}
const apiResponseValidator = createValidator(ApiResponse);
fetch('undefined.example.com/data')
.then(response => response.json())
.then(apiResponseValidator)
.then(validatedData => {
// work with validated data
})
.catch(error => {
// handle validation error
});
Avoiding Type-Related Exceptions
Instead of throwing exceptions, we can use errors as data to handle failures more elegantly. This approach allows us to work with failures as data instead of a GOTO-like control flow.
We can use union types to present successful operations as records with associated values and failures as errors with descriptive messages.
type Result = { ok: true; value: T } | { ok: false; error: string };
const divide = (a: number, b: number): Result => {
if (b === 0) {
return { ok: false, error: 'Cannot divide by zero' };
}
return { ok: true, value: a / b };
};
Functional Programming with fp-ts
By using functional programming concepts from fp-ts, we can build pipelines of operations that compose together seamlessly.
This approach allows us to transform validated data in a clear and concise way. We can also use libraries like io-ts to build robust and reliable applications.
import { pipe } from 'fp-ts/lib/pipeable';
import { map } from 'fp-ts/lib/Map';
const data = [1, 2, 3, 4, 5];
const doubleNumbers = pipe(
data,
map(x => x * 2)
);
console.log(doubleNumbers); // [2, 4, 6, 8, 10]