Runtime Type Safety in Typescript
In many ways Javascript popularity during so many years is based on its relative simplicity and low entry threshold. Some programming languages substantially influenced the whole software industry. Smalltalk, for instance, became some kind of grandpa for object oriented programming, though Smalltalk itself is not used a lot. But Javascript... to me this is the language whose creators didn't understand the whole size of its potential. JS is mostly known for its flexibility, but I always wanted to work with something on the edge between JS flexibility and rigidity of strongly typed languages. Is it even possible?
Before I proceed, just want to mention the following material is what I personally think about programming in general and Typescript and Javascript in particular. It is just my opinion, I don't pretend to being 100% right, I don't believe like I have all the answers. There are other opinions in this variable world of programming.
History notes
Javascript was introduced at the end of 1995. This language was built in to the Netscape Navigator browser. Do you even remember this browser? It was cool for that times, but didn't survive battle with Internet Explorer (which was simply published for free). Netscape Navigator died, but Javascript has firmly settled down. The interesting fact is that initially JS was aimed to address relatively simple things, bring some interactivity to browser web pages: put some animation, react on button click.
World wide web in 90th was different than today. It was very static, no animation, no interactivity, just pure HTML and CSS. Founder of Netscape considered, that world needed new script language, which can work with DOM. And, which is important, it should not be intended for developers or engineers. They already had Java. Instead, new language targeted designers. Think about it!
According to the recent Stackoverflow research , almost 68% of software developers use Javascript. Whatever we say about it - JS means a lot.
Despite the very short Javascript creation timeline (people needed to hurry and worked on the initial language version just several weeks), it has mighty features, which define it as programming language and allowed to grow beyond its initial frames and borders. In the same time, Javascript has brought a lot of initial features and quirks throughout its evolution till now. From one point of view, Javascript extravagance defines its well-known flexibility, but from the other side - make developer life harder in some sense. I bet you know joke about bookstore. Client wants to buy two books about Javascript for 9$ each and pays 99$. This is well-known fact of Javascript, absolutely different objects can behave very similarly, objects of different types can work together as if they are similar. What I'm talking about, there are no types in JS.
Long evolution followed JS birth. It evolved into ECMA Script standards with new features like let
, const
, promises, even classes as some kind of abstraction over prototypes.
If we look around, almost everything around us in web was written in JS. It was written with blood and sweat of software engineers. Even backend has Javascript now - look to the NodeJS. This fact defined the important role of Javascript as FullStack programming language.
I'm talking about all this, because it's my personal huge pain. After years of strongly typed C# it was extremely hard to me to start doing some work in Javascript. And even more - I couldn't do it!
Why? Because my strongly typed head cannot even imagine that we can write some code and then run it without any compilation. And after that put some newly made up field into the object, right during program execution. And send it all to the production server. I lack some basic starting point, some minimal control, prove that my code is OK to computer.
I wish that someday JS flexibility meet C# strong types. And keep using it in runtime.
About strong and weak types
Typescript helped me with this issue, because it is exactly something in between Javascript and C#. Which is very natural. C# and Typescript was created by the same person - Anders Hejlsberg, who also participated Turbo Pascal and Delphi creation. Those two were a kind of programming industry standards back in the end of 90th - beginning of 2000th.
Javascript is interpreted language. It's rather fast, comfortable to write code, low threshold, a lot of new features during recent years. But still no adequate types.
JS is weakly typed, but we have Typescript, which is (still dynamic) strongly typed. This helps to prevent number of typos, errors of missing properties and other issues even before the first run of a program! This is what I wanted. But is it?
You might say it's all redundant, we have great IDE which shows us all errors. You are right, but IDE doesn't guarantee that you personally will fix all the errors, which IDE shows to you.
TS gives us compilation and types! Hooray! Now if you screw up with the name of variable, you will know about that immediately. Instead of having email from customer after couple of weeks of beta testing. This is Gods blessing for me. Typescript actually helped me to jump into the real life web development, helped me to move a bit my coding paradigm so that I can create something meaningful for browser or NodeJS.
In general, static code analysis helps to reduce huge amount of errors, even in strongly typed languages. Back in 2012 I have listened to the talk about static code analyzer for C#, speaker claimed that his company code analyzer helps to reduce 40% of potential errors in C# code without single run.
Language is not a programming language if you cannot break anything with it. So, Typescript also has own backdoors leading to the old good Javascript flexibility. Yeah, I mean ANY keyword. When doing meetup talks I try to force myself to delete everything about ANY from the presentation, we already talked about it too much. But we keep using it in so many places. So, I cannot keep silence about it.
Typescript and ANY
TS allows to write Typed code, to specify types of function arguments and function output results. It even has generics. But still we can open a backdoor - not specified type in most cases mean it is ANY. A lot of projects has ANY in the code. Just because it's easy, it lets you prototype something and not think about.... what? not think? why we should not think?
Simple example:
public ngOnInit(): void {
const movie = {
name: 'Game of Thrones',
};
this.showToast(movie);
}
private showToast(item: any): void {
this.toaster.show(`Today's movie is: ${item.name}`);
}
Everything is fine here, working as expected. But what if we do refactoring and decide that word 'name' is not accurate representation of movie title, word 'title' is much better.
If we change it - compilation will be OK, because we expect ANY as function input argument. But if we put it into production, we will see the word 'undefined' somewhere in the user interface. As you get, we could spent dozens of valuable hours looking for this small issue.
From the other side, if we remove ANY and put exact interface or type as function input argument, we will transfer this responsibility to the compiler.
interface Movie {
title: string;
}
public ngOnInit(): void {
const movie: Movie = {
name: 'Game of Thrones',
};
this.showToast(movie);
}
private showToast(item: Movie): void {
this.toaster.show(`Today's movie is: ${item.name}`);
}
And from now, all such errors will be caught during compilation process, compiler just gives us an error like this before program start:
TS2339: Property 'name' does not exist on type 'Movie'.
By the way, if you don't trust yourself, you can turn on the rule no-any in linter. Checkout other linter rules here.
Typescript Compilation and Types in Runtime
All this is very good and we practically got to the point. Typescript doesn't solve fundamental issues of Javascript in runtime. What we have after compilation? Right - pure Javascript code. Clean, optimized, even minimized if you configured it. And this JS code will be executed in browser or NodeJS. It simply means that all our Types were working only during code development, and are lost in runtime.
Let's see TS in action.
export class SampleService {
public multiply(num: number, num2: number): number {
return num * num2;
}
}
We have class and some public function, we have types everywhere. If we do something wrong - compiler points us to the error, like this:
Let's compile this code to Javascript. To do this, run command tsc
in the code directory. The result looks like this:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class SampleService {
multiply(num, num2) {
return num * num2;
}
}
exports.SampleService = SampleService;
Easy to see, there is no types at all, in runtime code is just trying to perform some action. And depending of types and values of arguments, we can see different results:
const arg1 = 2;
const arg2 = 3;
const result1 = service.multiply(arg1, arg2);
// result = 6
const arg1 = 2;
const arg2 = '3';
const result1 = service.multiply(arg1, arg2);
// result = 6, несмотря на разницу в типах, JS сможет провести операцию умножения. В JSON все в виде строк в любом случае
const arg1 = 2;
const arg2 = 'foo';
const result1 = service.multiply(arg1, arg2);
// result = NaN
NaN
as a result is displayed for all cases when JS doesn't know what to do, but based on arithmetic symbol guesses that result should be a Number. In other cases, if we have some object, and function receives some not expected object, JS will try to work with object fields anyway. Of course, sooner or later we will have undefined somewhere.
Why do we need types checking?
Ok, basically all works as expected. Javascript is the most flexible language. All what I wrote is expected JS behavior. Working with TS we make a step forward anyway, having statical code analysis. So, in the ideal scenario when we have 100% control over 100% of code, there should be no problem.
Let's think about possible scenarios where actual type can be different from what we expected, even if TS compiler has checked everything.
Far-fetched scenarios:
- Frontend - getting server side results. When we work with object from server JSON, we usually have either ANY, or our Type, but without correct type cast.
- Server - getting client request. We receive JSON request from potentially unknown client, and even if we have Typescript on the server side (hello NestJS), in a runtime we have what we received from client.
- Client side code break-in. This is rare case, I guess, but still real. Client JS code of fully controlled by user. Of course, if user is so advanced that can read minified code.
More realistic scenarios:
- ANY. This just nullifies all TS efforts
- Library. Our code is called from the uncontrollable outside
- Object fields literal access (
obj['prop']
) - no type checks during compilation - Pure Javascript in our Typescript code.
Truth is in simplicity and expected behavior
I've been thinking about all this due to the several reasons.
First of all, I came from C++ and .NET. Paradigm of strongly typed languages is in my blood. But this doesn't prevent me to work with Typescript for years. Benefit of seamless work with JSON means a lot, and speed is acceptable even for some server side work in NodeJS.
Secondly, I have project necessity to verify types in runtime. We do sequential migration of old and bugged mix of JS/TS code to the new well structured Typescript code base.We need some ability to check types in runtime, when new code is called by range of places in the old code base.
Most of these problems can be successfully solved by data validation. For instance, on the server side we can add validation middleware to validate all income requests. I will provide a sample bit later. This is a good example how to validate entities with complicated business logic.
But I miss something simple, stupid and obvious. Like this:
public multiplyCustomChecked(num: number, num2: number): number {
if (typeof num !== 'number') {
throw Error(`Incorrect argument 1 type: ${typeof num}`)
}
if (typeof num2 !== 'number') {
throw Error(`Incorrect argument 2 type: ${typeof num2}`)
}
return num * num2;
}
If you call this function with incorrect arguments, you will have expected error in runtime:
const arg1 = 2;
const arg2 = 'foo';
const result1 = service.multiplyCustomChecked(arg1, arg2);
// result = Error: Argument 2 of function multiplyCustomChecked has incorrect type: string
const arg1 = 2;
const arg2 = { name: 'test' };
const result1 = service.multiplyCustomChecked(arg1, arg2);
// result = Error: Argument 2 of function multiplyCustomChecked has incorrect type: object
Type Validation Decorator
Obviously, we are lazy to write and support such check everywhere. Let's make it generic with decorator:
import 'reflect-metadata';
export function Typed() {
return (target: Object, propertyName: string,
descriptor: TypedPropertyDescriptor<Function>) => {
const method = descriptor.value;
descriptor.value = function() {
checkTypes(target, propertyName, arguments);
return method.apply(this, arguments);
};
};
}
Type Validation function can look like this:
function checkTypes(
target: Object, propertyName: string, args: any[]): void {
const paramTypes = Reflect.getMetadata(
'design:paramtypes', target, propertyName);
paramTypes.forEach((param, index) => {
const actualType = typeof arguments[index];
const expectedType =
param instanceof Function ? typeof param() : param.name;
if (actualType !== expectedType) {
throw new Error(`Argument: ${index} of function ${propertyName}
has type: ${actualType} different from
expected type: ${expectedType}.`);
}
});
}
This validation can be easily used, just put decorator to needed function:
@Typed()
public multiplyChecked(num: number, num2: number): number {
return num * num2;
}
Incorrect arguments in runtime will force this check to throw the error. We can catch it and log. Anyway all incorrect arguments will not be hidden anymore. And all we need to do - just put decorator to the function.
Of course, this is very minimal basic version just for demonstration. It can verify just limited scenarios.
More about decorators
You might know that decorator is just a function, which is called by autogenerated code in runtime. It receives following as input arguments:
target: Object
- the object where function was called, instance of that class.propertyName: string
- function name.descriptor: TypedPropertyDescriptor<Function>
- some descriptor object which allows to call desired function.
Action plan is simple:
- save link to desired function -
descriptor.value
, - override
descriptor.value
with our function, which executes all type checks - call desired function with
method.apply(this, arguments)
, wheremethod
- saved link todescriptor.value
Type checks itself are possible due to Typescript metadata.
Call to Reflect.getMetadata('design:paramtypes', target, propertyName)
returns list of desired function expected parameters. Current arguments are available in arguments
JS variable.
Good to mention, that decorators and metadata are experimental Typescript features and needs to be turned on in tsconfig.json file. You can read more about it in my article Mighty Decorators in Typescript.
The important thing, as I understood much later, is that current version of Typescript doesn't provide all metadata by design. For example, it doesn't fill type of Array items.
Another thing to know, unfortunately Decorator is applicable only to functions, which are written inside some class. This is also Typescript limitation. This is unfortunate for React lovers.
Worth to mention, every parameter in result of Reflect.getMetadata
output is actually a Function, as nearly everything else in Javascript. You can get Reflect.getMetadata(...)[0].name
to compare actual result and expected.
This solution, obviously, is created for demonstration of huge Typescript potential. Production usage in 100% is limited due to natural Typescript limitation at the moment. But nevertheless I get one step closer to strong types in JS / TS in runtime.
Performance
The essential question is performance. Let's compare clean function call and decorated one. I measured performance with console.time
and console.timeEnd
.
- average time of clean function: 0.08ms
- average time of decorated function with type checks: 0.3ms. I measured after warm up, second and other calls.
It's more than time of clean function call. Actually, 0.22ms is taken for every type check. Part of it is dedicated to metadata. But if we dig deeper, significant part of this is taken by forEach loop. So there is room for optimization.
First Run
part | foreach | for |
---|---|---|
clean_func | 0.113ms | 0.080ms |
metadata | 0.208ms | 0.148ms |
typeof | 0.063ms | 0.045ms |
foreach | 0.303ms | 0.163ms |
checked_func | 0.785ms | 0.515ms |
Second Run
part | foreach | for |
---|---|---|
clean_func | 0.113ms | 0.080ms |
metadata | 0.012ms | 0.031ms |
typeof | 0.002ms | 0.001ms |
foreach | 0.066ms | 0.040ms |
checked_func | 0.140ms | 0.221ms |
Numbers as is are not super representative, but we can see a trend - minimal influence of metadata and checks itself.
Ready to use implementation
I prepared code and npm package, which has stable version of described here @Typed
decorator. You can try it!
I will be very grateful for any feedback as issue in github.
Other solutions for runtime validation
To make the story complete, I'd like to share other well known solutions for data validation, which has different idea, but proved themselves as useful and popular.
IO-TS Runtime type system for IO decoding/encoding
This library has own type system and reminds me some ORM models, rather than simple validation. Library authors suggest to define validation and type checking rules as here:
import * as t from 'io-ts'
const User = t.type({
userId: t.number,
name: t.string
})
After that all checks are done automatically in runtime. Repository has much more samples and description. I like this approach, but I'm afraid that usage if it means near complete re-writing of existing project type system.
Superstruct-TS Typescript customer transformer for json validation based on type
This very interesting library took another approach as foundation. It works with regular TS types, but introduces validation function.
import { validate } from "superstruct-ts-transformer";
type User = {
name: string;
alive: boolean;
};
const obj = validate<User>(JSON.parse('{ "name": "Me", "alive": true }'));
Probably, this is the cheapest way to check object from server side. But there are some limitations:
- you need to use special
ttypescript
compiler. Regulartsc
doesn't help here - basically, this is just JSON validation. I cannot imagine that we use JSON.stringify every time when we need to check type
Class-Validator Validation made easy using TypeScript decorators
This library is closer to the initial idea, which I put into the article. Essentially, you define validation (and type) rules with decorators for class fields.
export class Post {
@IsString()
title: string;
@IsInt()
@Min(0)
@Max(10)
rating: number;
@IsEmail()
email: string;
@IsDate()
createDate: Date;
}
And then call function validate
whenever you need it. This function returns Promise object, so that you can see errors in catch handler. Repository has detailed samples.
This library is also interesting because it is widely used in backend framework NestJS. It has out-of-the-box ability to turn on this validation on the request level for the whole API, or for particular controller.
async function bootstrap() {
const app = await NestFactory.create(ApplicationModule);
app.useGlobalPipes(new ValidationPipe()); // turn on all controllers validation
await app.listen(3000);
}
bootstrap();
@UsePipes(new ValidationPipe()) // turn on this controller validation
@Controller()
export class AppController {
@Get()
healthCheck(): string {
return 'up and running';
}
}
Both samples turn on request validation with class-validator.
Conclusion
Perhaps one of the main quality of a good code is its predictability, when the code works as developer expects it in every situation. With the article I tried to share my thoughts how to reduce amount of extra ordinary situations with code and with that increase code predictability. Validation of types in runtime makes JS / TS code closer to strongly typed languages for me personally. Such approach helps to prevent a lot of potential errors. As a result, we can get valuable flexibility of interpreted JS and strong type check from language like C#. Having this I can invest more of my time and efforts to architecture and code writing and less to debugging.
Write you code with passion and pleasure!