TypeScript has a crazy powerful type system because it has to be able to describe any crazy behavior that was implemented in JavaScript. I mean, just take a look at @types/express-serve-static-core [0] or @types/lodash [1] to see the lengths TS will let you go.
If you write in TS to start with, you can use a more sane subset.
Right. I'm aware. My point was that even though the type system is powerful, somehow, I'm able to represent everything I need to in Kotlin's type system just fine and it feels a lot more type safe because it will throw a type error at runtime in the right place if I do a bad cast.
Typescript `as Foo` will not do anything at runtime, and it will just keep on going, then throw a type error somewhere else later (possibly across an async boundary).
You can, in theory use very strong lint rules (disallow `as` operator in favour of Zod, disallow postfix ! operator), but no actual codebase that I've worked on has these checks. Even the ones with the strictest checks enabled have gaps.
Not to mention, there's intentional unsoundness in the type system, so even if you wanted, you couldn't really create a save subset of TS.
Then there's the issue of reading the library types of some generic heavy code. When I "go to definition" in my fastify codebase, I see stuff like this
> = (
this: FastifyInstance<RawServer, RawRequest, RawReply, Logger, TypeProvider>,
request: FastifyRequest<RouteGeneric, RawServer, RawRequest, SchemaCompiler, TypeProvider, ContextConfig, Logger>,
reply: FastifyReply<RouteGeneric, RawServer, RawRequest, RawReply, ContextConfig, SchemaCompiler, TypeProvider>
// This return type used to be a generic type argument. Due to TypeScript's inference of return types, this rendered returns unchecked.
) => ResolveFastifyReplyReturnType<TypeProvider, SchemaCompiler, RouteGeneric>
Other languages somehow don't need types this complicated and they're still safer at runtime :shrug:
> You can, in theory use very strong lint rules (disallow `as` operator in favour of Zod, disallow postfix ! operator), but no actual codebase that I've worked on has these checks. Even the ones with the strictest checks enabled have gaps.
That's surprising. I've worked on a few codebases with reasonably mature TS usage and they've all disallowed as/!/any without an explicit comment to disable the rule and explain why the use is required there.
I don't write Kotlin, but what that does (assuming I'm guessing at it correctly) requires far more awkward code in most other languages. That looks like it will allow you to extend the types of objects deep inside the library so that you could e.g. create your own Request object without having to type cast inside the HTTP handlers or wrap the entire library.
That shifts the complexity of doing that out of the runtime and into the Typescript preprocessor where it's not going to mess with your production instances.
I also don't think it's all that bad; it's a lot of generic types, but it doesn't appear to be doing anything particularly complicated.
I do think they get awful, though. This is something I've been hacking on that I'm probably going to rewrite https://pastebin.com/VszX3MyE It's a wrapper around Electron's IPC and derives a type for the client from the type for the server (has to have the same methods and does some type finagling to strip out the server-specific types). It also dynamically generates a client based on the server prototype. The whole thing rapidly fell into the "neat but too complicated to be practical" hole.
> I don't write Kotlin, but what that does (assuming I'm guessing at it correctly) requires far more awkward code in most other languages
You're NOT assuming correctly. In Kotlin, this would be handled as an extension property on the Request type. You could write it just like normal code instead of extending some global ambient interfaces.
val Request.foo: Bar = whatever
get("/") { req.foo // just a method call }
You can CMD+Click on it and read the actual implementation (instead of generated type definitions).
The Typescript ecosystem needs these complicated types because of some design choices (no type based dispatch). I suggest looking up how other languages solve these problems. You'll find that in typescript, you have to reach for complex types far sooner than in other languages.
So if I pass that Request option to another function that expects a Request object, the compiler is going to track that I’m using an extended object instead of the actual Request type in the function signature and allow me to access the “foo” property?
That’s what I’m specifically talking about. Yours is just dependency injecting a type, which is more avoiding the existing types in the library. That would be the “wrapping” option I was talking about. You don’t need to extend the types if you’re just going to dependency inject them. You could just have an entirely separate object that you pass around at that point.
If the other method has visibility of my foo property, then it can call it. Nothing is being "injected" anywhere. It's the moral equivalent of foo(req). It's statically dispatched
That is exactly dependency injection, the functions have to be embedded into a scope that has access to additional values because the framework doesn’t support passing them directly. The types are injected into the function by being embedded into another scope.
It’s not some kind of moral sin, but it is a kludge. The type system is now tied to the structure of your code, because scoping is now intrinsic to your types.
It’s not the end of the world, I’ve worked in similar systems, it just tends to have a heavy mental overhead at some point as you now have to keep scoping in mind as part of your types.
The complexity of the types you see in traditional nodejs web servers is there because all of them tried to work as a god-function multitool. The type of a router handler literally incorporates the types of all constituents of a generic request-reponse process and additional server-specific data, all packed into (req, res). This only happens in ultra-generic code that is basically web servers only.
Other languages (libraries really) separate these parts, e.g. Spring Boot seems to hide routing away into a method decorator which infers the body type from a target signature and the server is somehow implied(? through a controller?..). Anyway, it's all there, just not in one place. It has nothing to do with Typescript, it's a js library legacy issue.
It's apples and oranges. The type system in Kotlin is integrated into the language. TypeScript is a preprocessor for JavaScript that isn't allowed to change the JavaScript language, or have any runtime.
The "as Foo" construct is for you to tell TS that you know better than it, or that you deliberately want to bypass the type system. You can have a runtime check, but you have to write the code yourself (a type predicate), because TS doesn't write or change any JavaScript*, it just type-checks it.
I've certainly worked in new codebases where a relatively simple subset of TS types were used. Even then there were a few places the crazy type system was helpful to have. For example, we enforced that the names of the properties in the global configuration object weren't allowed to have consecutive uppercase letters!
(* with minor exceptions like transpiling for new JS features)
If you write in TS to start with, you can use a more sane subset.
[0] https://github.com/DefinitelyTyped/DefinitelyTyped/blob/mast...
[1] https://github.com/DefinitelyTyped/DefinitelyTyped/blob/mast...