Recently my friend started learning TypeScript, yesterday he came to me with a doubt, he ran into a problem and he couldn't figure it out for days. (He is a newbie but a curious learner). His doubt made me realize a common misconception some people have about TypeScript. So I am writing this post to explain what leads to the misconception and how it can be rectified. Please note: This post is for newbies and aspiring JavaScript developers, an experienced developer would feel this is an obvious thing.
A brief overview:
My friend was actually trying to build a front-end using React and TypeScript. I may not reveal the exact details of what he was trying to achieve but I try to give similar example. His setup had a web-server written using Express.js and it had some APIs. Some part of the front-end code made a GET request to the API and receives the response in JSON format, then it would manipulate the content to display the result on the web-page. I will try to simulate such environment by writing two script files.
- The web-server code, in the simulation, the web-server will have a dummy end-point and returns a dummy JSON object upon request.
- The front-end code, which is just a script in my case, it makes HTTP GET request and fetches the object, it them performs a simple operation on that object and console logs the result, written in TypeScript, which I will compile to JavaScript using the official type-script compiler.
The server code: (server.js
)
const express = require('express')
app = express()
app.get("/dummy", (req, resp) => {
return resp.status(200).json({
dummyValue : 121
})
})
app.listen(6000, () => {
console.log("Running server")
})
This code looks simple, it should be clear that the server returns an integer field called dummyValue
with some random value.
The client: (client.ts
)
import axios, { AxiosResponse } from 'axios'
interface DummyResponse {
dummyValue: number;
}
const generateResult = (response: DummyResponse): number => {
return response.dummyValue + 1
}
const makeRequest = async (url: string) => {
try {
const response: AxiosResponse<DummyResponse> = await axios.get(url)
if (response.status !== 200) {
throw `Got response ${response.status}`
}
if (!response.data) {
throw "No data in the response"
}
const respJson: DummyResponse = response.data
const result: number = generateResult(respJson)
console.log(`Result : ${result}`)
} catch (err) {
console.log(`Failed to get response err string = ${err}`)
}
}
makeRequest('http://localhost:6000/dummy')
The client code is written in TypeScript, the script uses axios
to make an HTTP GET request, the script clearly defines interfaces and types wherever necessary. generateResult
function takes the response object and increments dummyValue
by 1. Then the value is simply returned. You can also have this package.json if you wish to reproduce:
{
"name": "client",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"build-client": "tsc client.ts",
"test-client": "node client.js",
"start-server": "node server.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"@types/node": "^14.14.35",
"axios": "^0.21.1",
"express": "^4.17.1",
"typescript": "^4.2.3"
}
}
I have defined three script commands here. build-client
will build the JavaScript file client.js
from client.ts
using tsc
. test-client
will run the generated client.js
file using the local node.js environment. start-server
will start the web-server written using express.js
.
To compile and get started, you can copy these three files locally, and run the following commands:
npm i
npm run build-client
The problem:
As of now, let's run the server and test the client script.
To run the server:
npm run start-server
Next, in another terminal you can run the client:
npm run test-client
The client produces the following output as expected:
Result : 122
This is fine, the client just did what it was expected to do, it made a request to http://localhost:6000/dummy
and got the result {'dummyValue' : 121}
, it then added 1 to the dummyValue
and so the result is 122.
Now we will change the server side code a bit, but we will not touch the client side, we reuse the same compiled JavaScript client.js
. Let's change the server code like this:
const express = require('express')
app = express()
app.get("/dummy", (req, resp) => {
return resp.status(200).json({
dummyValue : "121" //dummyValue has a string value now.
})
})
app.listen(6000, () => {
console.log("Running server")
})
We did a very simple change, we just made dummyValue
to contain a string value instead of a number type. We start the server again and run the same client code again, but we get the following output:
Result : 1211
We got an output, without any failures, but is this correct?? Of course not!! This is a very big difference, instead of getting 122 we got 1211, imagine how this wrong result can mess up further computations, or imagine how damaging this would be if this is some blood-preassure monitoring system or something related to healthcare!! LoL. The developer who built the front-end would not be aware of this after deployment unless he checks the app periodically.
My friend couldn't figure this out because the code still worked fine and it did not return any errors or warnings, but he came to me when he noticed the issue. (Remember, he is new to JavaScript)
Keeping this in mind, let's dive into the concepts.
Why people use TypeScript??
First, you have to understand why we need TypeScript when coding with JavaScript is enough for most of the use-cases. People use TypeScript for compile-time type-safety. As per the definition from Microsoft, TypeScript is a superset of JavaScript and provides many built-in features that are absent in vanilla JavaScript. Compile-Time type-checking is one of the major features. In general, TypeScript is an extension to JavaScript where every symbol has a fixed/static type. JavaScript doesn't care about the types, the variables/constants can take any value and they can be changed as well (for variables) at any point. So JavaScript is an untyped
language. People can find untyped languages like JavaScript easier to use, but the real issue arise when codebase grows larger, you might end up in a point where you no longer can keep track of the variables you used and the type of each variable, because you have to keep track of type-safety by yourself to maintain integrity of results and to avoid unnecessary bugs.
TypeScript solves this problem, it binds a static type to each of the symbol you use and keeps track of the assignments by itself, since TypeScript does this for you, you don't have to worry about doing it yourself. Thus, TypeScript makes larger codebases easier to maintain and shareable across developers and teams. But wait! There is a catch.
Compile Time v/s Runtime Type-checking:
Even though TypeScript is a superset of JavaScript, it cannot run on it's own. It is just a JavaScript generator. In the end, the TypeScript code gets compiled to plain JavaScript. The JavaScript generated from TypeScript can be executed on any JavaScript implementation. During compilation, TypeScript compiler checks for type-mismatches and report such errors. For example, a number can be added to only another number, you cannot add a string to a number. Since such issues are reported at compile-time, developer can make sure there are no bugs due to mixing of types in production, which is a good thing!
This doesn't mean such issues will never occur again. Because the type-safety is evaluated only once i.e during the compilation and never again. The JavaScript that gets generated from TypeScript doesn't embed any type related information as JavaScript implicitly does not care about types. In other words, at runtime your application is still not type-safe.
Issues in handling Dynamic Data (no fixed schema/types)
Since TypeScript gaurentees only Compile-Time safety, no errors at compile-time doesn't mean your application will never crash. At compile-time there is no dynamism, that means, you assumed one fixed data-type and worked on it. Imagine this data comes from an external source (from an external API/service), then the data is dynamic and its structure or type can change at anytime, but the application you wrote and deployed using TypeScript will not take this into account, because at runtime, your typescript application exists as a plain untyped JavaScript. In most of the cases, the type-conversion is automatic and works on the principles defined by JavaScript implementation, for example a number is type-casted to string when added with another string, this happens silently and without any notification or exception. This is the most common type of bugs in JavaScript, as many websites deal with unpredictable and dynamic data from external/third-party APIs.
In the example I considered, the client code statically defined the type of the API response through DummyResponse
interface, the interface assumed the dummyValue
key to be a number type, because of this reason the function generateOutput
was able to add 1 to the dummyValue
without any compile-time errors, since both the values of addition were of the same type. In case two, however, the type of dummyValue
changed to a string on the server-side, but the client was not aware of this change, even though this was against the principles of TypeScript, the error was ignored because it was the Runtime JavaScript that saw the dynamic output and performed the operation without considering the type of dummyValue
.
This is not the case in any strongly-typed language because these languages will at least throw a runtime exception or an error. (Languages like Go and Java)
Is this really a problem of TypeScript?
No, it is not, because TypeScript never promised run-time type-checks implicitly. People often misunderstand this and assume TypeScript provides both run-time and compile-time type safety. The notion of a Type
goes away once the script is compiled. If you are familiar with Python, you can compare TypeScript to Python's Type-System, these tools exist to help developers to get rid of bugs and headache during development, but many people assume it can handle both cases of type checking. This happens because of the knowledge gap, a developer who is not aware of Types or Type checking might fail to understand this limitation of TypeScript and ignore to do explicit type-checks on dynamic data.
How to avoid this problem?
The solution to this problem is straightforward, do explict type-checks, I can modify generateOutput
function to incorporate explicit type-check as follows:
const generateResult = (response: DummyResponse): number => {
try {
if (typeof response.dummyValue !== "number") {
throw `Improper type of dummyValue, expected number, got ${typeof response.dummyValue}`
}
return response.dummyValue + 1
} catch (err) {
console.log(`Failed to generate result, error = ${err}`)
return NaN
}
}
The function performs type-check and throws an exception if the condition is not satisfied. There are literally hundreds of ways to perform explicit type-checking. If you don't want to write code yourself for type-checking or you deal with a complex data with many nested objects, then you can consider using some popular validation library from npm. Here I try list a few of them:
These libraries can perform validation on complex objects using simple schema definitions. You can also look at how Mongoose ODM does schema validation against MongoDB data and follow a similar schema structure.
Avoiding explicit Type-checks at front-end:
There is no suitable way to get rid of type validation completely, because JavaScript doesn't perform type-checks implicitly, but you can avoid it to some extent by changing your application architecture, here are few tips:
- Implement dynamic schema validation system and let the front-end fetch infer the schema from the backend and change it's validation flow accordingly, by this way you can avoid changing schema at multiple places. Check [https://json-ld.org/] for a similar analogy.
- Do not consume data from external/third-party APIs directly, build a backend service that acts like a proxy, implement validation function in the backend, this backend can also filter-out some unnecessary fields that are not required by the front-end. By this way you can keep the front-end clean and handle all the complexities at the backend, this is also a good security practice. Check Dependency inversion principle
- Consider using GraphQL as it performs validation internally.
- You can also consider Protobuf + gRPC instead of HTTP/s + REST.
Thanks for reading!
Top comments (12)
I think it's misleading to frame this as a matter of compile-time vs runtime type-checking. In this line:
You are lying to Typescript. It's not clear that you're doing it, but you are. You tell Typescript that it's a DummyResponse, but it's not. It's akin to
DummyResponse *respJson = (DummyResponse *)(void *)response.data
in a language like C++.A valid static type-checker verifies that if the inputs have the type that the programmer says they do, then there will be no type-related explosions. If the inputs are not as the programmer says they are, there's not much that any compiler can do to help. This isn't unique to Typescript -- it's universal. The issue is just that Typescript makes it very easy to lie about types without noticing. That's the important takeaway for people getting started with Typescript.
If an application depends on data from an external source (like REST APIs etc), the best way to integrate that data with your code-base would be to convert that data into any of the native structure supported by the language, in Python/JS you directly deserialize the data to a Dict/JSON object respectively. In strongly typed languages, you would define a structure which contains all the fields and schema information, you would deserialize and then type-cast that data to a struct. The problem here is not about conversion, but how well your client is safe against changes in the structure of external data? Of course, this is unavoidable, but if atleast a client code would crash or report an exception rather than blindly ignoring it and executing the further steps, it would be more reliable.
Strongly typed languages (i.e those who respect types at runtime) would throw exceptions, it can be at any place, for example, in Go, if the JSON you are trying to de-serialize is not compatible with the structure you defined, it would throw an error, stopping it from being ignored accidentally, even if it is ignored there somehow, the computation on incompatible data-type would throw an exception/error. Which is not true in untyped languages, the type is inferred at runtime, which leads to undesired conversions and improper results, an exception is thrown only when there is no other way. (even adding
undefined
to 1 gives youNaN
rather than throwing an exception)As you said, the actual problem with typed languages is universal, also there is no solution, there wouldn't be any because there is no way we can infer the type of an external data, unless it provides some way or unless we define its type by ourselves.
The focus here is to explain how weak JavaScript is when it comes to Runtime type safety and how TypeScript is not a good solution when it comes to Type safety at runtime. So the only way is to validate the data explicitly irrespective of TypeScript or JavaScript. Also in the end, if you see there is a mention of Protobuf and GraphQL, these tools validate and throw errors at de-serialization phase itself, so you don't have to worry about validating explicitly, just like strongly typed languages.
There are more JS engines than just V8. Typescript produces ES-spec compatible code which can be run on any ES-spec compatible JS engine.
Hey,
Yes you're right. I somehow wrote that, by mistake maybe. I've made an edit. Thanks.
Nice blog. Great way to demonstrate lack of dynamic type safety in Typescript.
I have written a blog on how to use io-ts to solve this problem. Check this out: tech.shaadi.com/2020/08/10/managin...
Thanks I'll give it a read
Cool post you touched on many relevant topics here.
Thanks
That is a great blog !
Thanks
Nice article. Thanks for sharing!!
Thank you.