Hacker News new | past | comments | ask | show | jobs | submit login
A 'CSS reset' for TypeScript, improving types for common JavaScript API's (github.com/total-typescript)
83 points by tsujp on March 3, 2023 | hide | past | favorite | 90 comments



With the ‘unknown’ type available is there a good case for ‘any’ anymore?

I really like the ‘unknown’ type. In catch statements for example the error is unknown but you can do dynamic checks to see what it is, then typescript lets you treat it that way.


The TypeScript team has said that if they could do it over, `any` would behave like `unknown`.


This is a good thing for serious, long lasting projects. However, it could be an overkill for something fast and dirty.

One thing I love about typescript that I've never seen in other languages is that you can adjust the type checker exactly for the level of rigidness that project at hand needs. Any is okay for 200 LOC quick prototypes.


This is a bit of a problem imo. It’s super easy to forget to add a type somewhere and then it just defaults to any. I’d prefer no implicit any so you have to knowingly set something as untyped.

It’s also just flat out impossible in many other languages because the compiler actually needs to know the types for performance reasons.


This is possible with a single `tsconfig.json` change[0]. This is also the default mode if you've set the 'strict' flag.

[0] https://www.typescriptlang.org/tsconfig#noImplicitAny


Yes, exactly.

Typescript managed to be the best of both worlds, as you can be both lenient when you're still in the design/prototyping phase of a project, and need to test idea quickly, but also add as much type-check as you see fit when you're maintaining a project, and need to interface with lots of existing code.

As such, I'm always a bit miffed when I see so many people adamant on setting it to maximum strictness by default, under the guise of "best practice".


As someone who has "strictified" a lot of code bases after the fact, starting out with `strict` is way less friction than adding it later on.

Especially `strictNullChecks`. It's a massive headache to deal with that later on.


Yup. And strict mode tends to guide devs towards better code/API design. Stuff like function/method overloads with dynamic argument types might seem convenient when the types are loosey goosey, but it usually becomes obvious they’re probably a bad idea when you have to specify the types upfront.


> Any is okay for 200 LOC quick prototypes

If you're writing a prototype (so the code will eventually be thrown away), it's just 200 LOC and you're using `any` for everything, why even use TypeScript? Should just use vanilla JavaScript instead.

And if you wanna break the rule of throwing away prototype code, you can always "upgrade" it to TypeScript later without too much fuzz.


> If you're writing a prototype (so the code will eventually be thrown away), it's just 200 LOC and you're using `any` for everything, why even use TypeScript? Should just use vanilla JavaScript instead.

The case where I've found this useful is making a quick and dirty prototype that integrates within an existing typescript codebase. Such a prototype would never make it to the main branch but sometimes it's useful to make a quick proof of concept of the the runtime behaviour before comitting to sorting out the types.


Who said using any for everything?

I love using TS for prototypes because you can define the interfaces wherever you feel they're important, and leave the rest to any.

And like you said, you can always improve the types later


Standard tooling. It's also still useful even if you just consume the types.


‘any’ is great for porting javascript into typescript, and integrating with javascript libraries. You can add types gradually, without everything grinding to a halt.


Adding types to code that was written with `any` is going to be much harder than using `unknown`.


The point is if you have existing JavaScript you want to integrate with TypeScript.

You should not need ‘any’ in code written in TypeScript from the beginning.


Sure, but at least with unknown you will have done some narrowing.

Writing Typescript well requires some fundamentally different patterns than javascript. Adding types afterward is like pulling out your teeth slowly.


That sounds extremely cumbersome. Sometimes I just want to throw an any on something and call a method on it without having to deal with types. How would unknown help with that? I can't call a method on a variable with unknown type.


You can use ‘as’ to cast unknown to any type. This is extra typing but it does signify to other developers that you’re calling a function on an unverified object, which I believe makes the code more maintainable in the long run.


Why bother using typescript then?


That 1% won't invalidate the remaining 99%.


> With the ‘unknown’ type available is there a good case for ‘any’ anymore?

Lets say you have some input json that you want to slightly modify to something else. How would you do this with unknown? I can't just blindly replace any with unknown. I'd get errors like this: The right-hand side of a 'for...in' statement must be of type 'any', an object type or a type parameter, but here has type 'unknown'.ts(2407) For example, how can I do this better?

Remember the input json could be pretty much anything. I don't have a spec other than I only care about things that end with __c.

https://github.com/kusl/salesforcecontactmapper/blob/eff0b3e...

    import { Output } from "./Output";
    import { Preference } from "./Preference";

    export function MyMap(input: unknown): Output {
        const mypreferences = Array<Preference>();
        for (const prefCode in input) {
            if (prefCode.endsWith("__c")) {
                if (prefCode === "IsInternalUpdate__c") {
                    continue;
                }
                let currentValue = "";
                if (input[prefCode] !== null) {
                    currentValue = input[prefCode].toString();
                }
                if (currentValue === "true") {
                    currentValue = "True";
                }
                if (currentValue === "false") {
                    currentValue = "False";
                }
                const preference: Preference = {
                    PrefCode: prefCode,
                    CurrentValue: currentValue
                }
                mypreferences.push(preference);
            }
        }
        const myOutput: Output = {
            ContactId: input.Contact__c,
            Email: input.ContactEmail__c,
            IsInternalUpdate: true,
            Preferences: mypreferences
        }
        return myOutput;
    }


well you expect 'input' to be something that you can iterate over, so clearly using any is wrong here.


yes, input is a json of some kind.

You could say technically could be simply { "Unsubscribe__c": false } or even {} both of which are silly in my case because there is no key for me to identify who the person is but they are valid inputs.

Or the test case I have is https://github.com/kusl/salesforcecontactmapper/blob/eff0b3e...

Or the input could have a thousand key values and I only care about some of them. What should my object look like? How do I create a class that says everything that ends in "__c" is something I care about? I tried unknown. I tried Object. How do I fix this (and learn something so I fix all future code I write)?


You can go incredibly far with the type system.

If you wanna go down that rabbit hole, I'd suggest Typescript type challenges. Completely blew my mind when I came across it the first time.

When you start out with typescript you might think Omit<> and Partial<> are cool but holy hell, you can do so much more.


    input: Record<string, unknown>


Thank you. That gives me a red underline under ContactId and Email now. I think because input.Contact__c and input.ContactEmail__c are unknown. I think this is the right direction. What is my next step?

    const myOutput: Output = {
        ContactId: input.Contact__c,
        Email: input.ContactEmail__c,
        IsInternalUpdate: true,
        Preferences: mypreferences
    }
(property) Output.ContactId: string Type 'unknown' is not assignable to type 'string'.ts(2322) Output.ts(4, 5): The expected type comes from property 'ContactId' which is declared here on type 'Output'


What's wrong with Object?


If I set input as Object,

    if (input[prefCode] !== null) {
        currentValue = input[prefCode].toString();
    }
in the lines above, I see a red underline under input[prefCode]

> Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Object'. No index signature with a parameter of type 'string' was found on type 'Object'.ts(7053)


Could you use a helper like

    function getProperty<T, K extends keyof T>(o: T, propertyName: K): T[K] {
        return o[propertyName];
    }

?


You can do some pretty powerful stuff with TypeScript types, so you could model it like this: https://www.typescriptlang.org/play?#code/C4TwDgpgBAysBOBLAd...


Nice I tbink that is the answer! It is for when the way you need to deal with the data is so dynamic and runtime specified that crafting out the interface/types to cast to would be painful to impossible. Data wrangling.


It's can be useful when you're integrating with 3rd party that doesn't support typescript and you just want to say "I don't care about types here". Of course you have to be careful as `any` turns off typing everywhere it appears. But if the usage is local within a function then this may not matter too much.


For one, it signals different intent, which tends to be important in larger projects with more collaborators. The more you can make intentions obvious, the easier it'll be for others to understand and contribute.

A value whose type we don't know is different from a value which we know could be any type.


Those sound like the same thing to me.


Here’s how I think about it, in much looser terms:

unknown = I don’t know

any = I don’t care

Often the implications are the same, but as you can intuit, any is a lot more relaxed.

Any will work anywhere because you just don’t care what the type is.

Unknown is stricter. It won’t work in cases where other types expect you to know what type you’re dealing with.

The compiler treats unknown very cautiously, whereas you can slap an any on pretty much anything.


Consider a ‘dump’ function. By design, it handles anything you throw at it - strings, objects, booleans, etc. It won’t change. That’s ‘Any’.

Whereas ‘Unknown’ might be a stepping stone to introduce a more specific type once all the use cases are known. I let it handle strings and integers, log a line about any other type that calls it, and continue behaviour as it is today.


Such a `dump` function would be 100% the same with unknown, since all types can be converted to top types (both any and unknown in TS)


The difference comes when you interface with other well-typed code and want to convert from any/unknown down to a specific type.

For example:

  function handleString(s: string) {
    //...
  }

  declare const a: any;
  declare const u: unknown;

  handleString(a); // works with zero intervention

  handleString(u); // error: Argument of type 'unknown' is not assignable to parameter of type 'string'

  handleString(u as string); // works again with explicit re-typing


A dump function accepting "unknown" is signalling "I'm not sure what this function could handle, probably a lot of different types" vs accepting "any" where it's telling you "I can accept any type you throw at me".


They're the same as much as "1 + 1 = 2" and "2 + 0 = 2" is the same. The result might be the same ("any" and "unknown" accepting any type) but again, the intent is different (a value we know can be anything vs a value we don't know what type it'll have).


It's a weird choice that this library makes some JS APIs more strict, and others less strict. Specifically, the changes to JSON/Array.isArray make sense, the value is unknown, and coercing it to any encourages leaking that type further, or runtime errors. However, the changes to includes/Set basically amount to "I said that this array contained this enum, but I changed my mind when I wrote this if statement". This actually allows more bugs to slip through, rather than fewer.


IIRC, in the past .json on fetch was taking the data type as a generic parameter. Then suddenly it got changed where the generic was removed and you need to cast it yourself. I wonder why that was done.


It was likely done to force the use of a cast (`as ...`) that is easily identified as an unsafe operation, whereas type parameters are not.


As a side note, in editors using js/ts language server from VSCode, you can benefit from these types even in "vanilla" JavaScript with JSDoc. It could look like:

    // @ts-check
    /// <reference path="./ts-reset-0.3.7/src/entrypoints/recommended.d.ts"/>
    // = contents of the archive from
    // https://github.com/total-typescript/ts-reset/releases
    
    const allCurrencies = /** @type {const} */(["EUR", "USD"]);
    /**
     * @typedef {typeof allCurrencies[number]} currency
     */
    
    allCurrencies.includes('XXX');
    // No 'Argument of type '"XXX"' is not assignable…' error any more.
    // Would be that error without ts-reset.
    
    /** @type {currency} */
    let foo = 'EUR'; // OK
    /** @type {currency} */
    let bar = 'XXX'; // that error, expected


I really can’t stand the appeal. It’s so much more verbose and clunky than Typescript. I want to understand why people choose it but I can’t justify using a worse, more verbose, less readable, and less powerful version of Typescript when I can just change a j to a t and have the real deal.


Not that I've implied it is better or worse or demanded subjective opinions about that, but when we are at it, here are some remarks:

1. No build step is an appealing feature for some.

Knowing that what is shipped is what you wrote, not what some transpiler spat out, might have some value.

2. Syntax is also subject of personal preference.

Anecdotally, I personally pretty much prefer having "non-executive content" swept aside the "executive" parts, not mixed together in single expression (when applicable).

I assume it is matter of habit, but even though I use both TS and JS, to this days, over TS

    function uppercase (something: string): string {
        return something.toUpperCase()
    }
I'd really rather prefer seeing the "fluff" (types and comments) separated from the "meat" (actual code): in JS (JSDoc)

    /**
     * @param {string} something - glyphs presumably not (only) from upper case
     * @returns {string} all glyphs from upper case, when applicable
     */
    function uppercase (something) {
        return something.toUpperCase()
    }
since I still keep mentally stumbling over those :fluffy chunks in TS. Again, probably mater of habit really.


> Knowing that what is shipped is what you wrote, not what some transpiler spat out, might have some value.

There aren't that many TS features that actually require transpiling unless you want to write bleeding edge JS, and that's still a factor without typescript.

> No build step is an appealing feature for some.

ts-node exists

My question is how do you reuse and test the JS doc signatures? Can I export comments? Will my tests fail if the types mismatch?


I'm working on a JS project for a client. I can't modify the deployment procedure so I can't add a build step and ts-node is no help because it's frontend.

JSDoc comments can be checked with TSC and I get squiggly lines in VSCode. It's a major step in the right direction. Obviously tests work the same they always did, there is no runtime type checking just as there isn't in TS.


You could build it before you check the code in.


I guess, but that seems at least as tedious as maintaining JSDoc comments to me.


I don't want to use Typescript for everything.

A recent example is a Chrome extension I wrote for the sake of proving out a feature.

Totally not interested in setting up a build system, dealing with dependencies, type safety, and so forth. I just want to write code and immediately see it run. Having JSDoc is nice to document more than just types, but type hints can be helpful.

> My question is how do you reuse and test the JS doc signatures? Can I export comments? Will my tests fail if the types mismatch?

You can import types now, at least with VS Code.

``` /* * @typedef {import('./types.js').MyType} MyType */ ```

Haven't found myself using it, though.


> You can import types now

When using TypeScript to avoid using TypeScript, yeah. It's not valid JSDoc: https://github.com/jsdoc/jsdoc/issues/1645


You can still use `tsc` to validate the jsdoc types. It will spit out errors when types don’t match.

You can use `tsc` to export the types defined in jsdoc and other projects that import your module will get all the intellisense and type checking as if it had been written in TypeScript.


I won’t speak for others, but I for one can’t stand the amount of extra packages needed to get a TypeScript project working. I need to install adapters for my linter, formatter, test runner, editor, bundler to name a few. Hopefully it all works together with all the other plugins and adapters. With the jsdoc version, I just install the `typescript` package, and I can use that to do the typechecking as well export types to be used by a TypeScript project. Sure it’s a bit more verbose, but I’d take that over tinkering with dependencies and configurations.


it doesn't require a build step though


I love it when my code fails to compile. Machines are good at finding problems. It is a feature, not a bug.


That's the point. Suggested setup (editor with JS/TS language server constantly checking your code) means you will know about every problem that would possibly break something, you just don't need separate "source code" producing "build package". It means you can ship your code directly to target platform that only understands JavaScript.

AFAIK that's not what you currently can do with TypeScript (except Deno).


All someone has to do is embed one of the fancy new rust tsc implementations into a Node distribution, and be done with it. Deno is not Node.


For reference, I've suggested such info could be mentioned in the project [0] and author sees as "too niche".

[0] https://github.com/total-typescript/ts-reset/issues/93


Why not just send a pull request to TypeScript if some of the types are incorrect?


So many libraries depends on TS and a PR like that would break a lot of them. I would say they need to put it under a flag.


That's what semantic versioning is for, breaking changes are inherently allowed in major version bumps.


In theory, yes.

In practice, here are a couple of examples:

A thread in relation to "replace `fgrep` with `grep -F`": https://news.ycombinator.com/item?id=33189503

How people feel about Python's handling of Python 2 vs Python 3: https://news.ycombinator.com/item?id=34227760


Semantic versioning isn’t really usable for typed languages as the majority of fixes would be considered breaking changes.


Being able to declare major changes in a version number says nothing about whether it's a good idea to make them.


they made the change from any to unknown in the catch handler so its not without precedent


So... a PR with a flag? :)


The Boolean one at least is pretty longstanding: https://github.com/microsoft/TypeScript/issues/16655

I imagine there are a lot of edge cases to wade through before mainlining.


I've been using kotlin-js for the past few years. Not an obvious choice for a lot of typescript users but IMHO something that would lead to a lot of obvious improvements to typescript if more people would. Like being more strict by default and getting rid of a lot of the javascript compatibility. This library seems like a noble attempt to patch it up somewhat. But why not go all the way and just fix the language properly?

Kotlin and typescript are actually similar enough that transitioning from one to the other isn't that big of a deal. The key difference is that typescript tries to maintain compatibility with javascript whereas it's just a compilation target for kotlin-js.

Like with typescript, you use type safe wrappers for any javascript code you need to access. You can actually reuse typescript type definition files and generate Kotlin ones from them. Not perfect, sadly, but it works for simple libraries and you can manually deal with the more complicated ones. I use things like fluent-js, maplibre, and a few other libraries. I don't use react, but a lot of kotlin-js developers do. There are no real limitations on what you can use here.

The one compromise kotlin-js makes to enable interfacing with javascript code is the dynamic keyword, which you use to do the bait and switch style APIs you see a lot in the javscript world. It could be a list, a number, or a string, etc. Dynamic allows you to work with such APIs. It's a necessary evil. But otherwise, it's all good. It's strict by default. It doesn't have a mode where it is not strict. And this is what enables IDEs like intellij to be a lot smarter with Kotlin than it is with typescript.

If you ignore the few syntax and language features that are unique to either Kotlin or Typescript, the key difference is that typescript is more sloppy and it's mostly because of js compatibility. And since kotlin-js has almost none of that and can manage fine without it, it kind of proves that this level of sloppiness is simply unnecessary and redundant. A whole lot of downsides and not a lot of upsides. Typescript is much more sloppy than it needs to be. Making it less sloppy is the obvious way to improve it. It's why I use kotlin-js instead.


This is basically how I feel about rescript these days. Typescript is really amazingly complex for what it ends up giving you. It's a big improvement over js, but I don't think that's the right comparison.

Using typescript we're already paying the costs of friction & indirection in tooling and learning another language. I know typescript has a ton of momentum and is unstoppable at this point but I wish things had turned out differently.


Interesting. Do these represent oversights in TypeScript's builtin type definitions, or are they artifacts of legacy considerations?


I think TypeScript's decisions about these methods were done either because early versions of the transpiler couldn't detect certain behaviour or to make the language friendlier to work with.

If you have a function `widgetifyFoo(object: Foo)` and some JSON that you're pretty sure returns a `Foo`, you'd like to be able to do `widgetifyFoo(JSON.parse(data))`. With `any` the method call should just work, but with `unknown` you need to be explicit in what kind of data you're expecting (`widgetifyFoo(JSON.parse(data) as Foo)`). That may be too verbose for some.

The `[1, 2, undefined].filter(x => !!x)` typing is just a limitation of the transpiler. The end result will be an array containing two numbers, but you need to do some complicated validation to get the type right; `filter`s can be very complex if you want them to be, and they do by contract return a (sub)selection of the input. The type guarantee you want as a developer is based on the implementation rather than the underlying type system.

I suspect the `Array.includes` implementation to be a choice by the devs. The check will only succeed if the type matches, so you have a choice between setting the right type on your array (`"matt"|"sofia"|"waqas"|"bryan"` if you want to check for `"bryan"`) or using the type error you receive as an indication your check makes no sense. If you have an immutable array of specifically `["matt","sofia"]`, why even check for `"bryan"`? It won't be there, unless you make a mistake!

Javascript will obviously happily call the method for you and do what you expect, so TypeScript is breaking valid JavaScript standard API code here. That said, I agree with the TypeScript devs that they made the right choice on that one.


> I suspect the `Array.includes` implementation to be a choice by the devs. The check will only succeed if the type matches, so you have a choice between setting the right type on your array (`"matt"|"sofia"|"waqas"|"bryan"` if you want to check for `"bryan"`) or using the type error you receive as an indication your check makes no sense. If you have an immutable array of specifically `["matt","sofia"]`, why even check for `"bryan"`? It won't be there, unless you make a mistake!

That logic only applies to searching for a constant, and in that case it should go a step further and complain that you're using .includes at all. There's no reason to search for the constant "bryan" or the constant "matt".

The only time it makes sense to use .includes on this array is with a string of [semi-]unknown contents. And yet that's what gets blocked. The typing is wrong.


But TypeScript does have typed strings. If the array contains arbitrary strings, then the includes() call would just succeed without error.


If you typed the array as string[], it would generally work, but then the compiler wouldn't be able to warn you that users.includes("matt") is always true. And it wouldn't be able to warn you that users.includes("bryan") is always false.

I could at least understand the use case for a narrow includes if you explicitly typed the array as `("matt"|"sofia"|"waqas")[]`. But that's not the type of the array. The type is `readonly ["matt", "sofia", "waqas"]`. There's always exactly one of each. There is never a reason to feed a string of type `"matt"|"sofia"|"waqas"` into includes. The only sensible parameters for includes are types that intersect with `"matt"|"sofia"|"waqas"` but can be other things too. Which means the most sensible parameter type to derive is `string`.


Interesting! The example

[1, 2, undefined].filter(Boolean). // number[]

is interesting because it makes use of the fact that the types of individual array members are known at compile time. I can think of some times when I have dealt with arrays that are defined at compile time, but not where I've needed to call .filter on such an array. Is there a common use case here?


I think that's just a contrived example. It looks like it should work with types like `(string | null)[]`, where individual members are only known on runtime.

That's very appealing to be; I commonly .map() an array to a nullable type, then want to filter out the nulls.


How could it help with anything at runtime? The type system only exists at compile time, right?


Yeah, it only exists at compile time, so in "real" code you won't know the type of e.g., array index 2 at compile-time, but you will know "each item in this array is either type A or type B".

You'll often want to work on only the A's in the array, and so you need to whittle down the data, but also inform TypeScript that you've done so. The return type on your .filter() callback tell TS what type the entire resulting array should be.

When filtering out nulls, you normally have to write a user-defined type guard everywhere you call .filter(), which is irksome, so this project configures the built-in Boolean() function to behave similarly when passed to .filter().


Reading https://github.com/total-typescript/ts-reset/blob/main/src/e..., I'm more confused than before. The generic NonFalsy<T>[] looks to me like it should evaluate to never[] if the entire array is false, and to T[] (the original array type, not narrowed) otherwise. But I can see that the test case demonstrates that it works. What am I missing?


I’m not sure anybody has ever convinced me that unknown is meaningfully better than any, in any scenario (pun). I get that it’s “typesafe” on the surface but we both know you’re going to turn around and cast it to the type you think it is so what’s the point


You can safely cast an `unknown` to a desired type with a runtime check from a library like Zod (https://zod.dev/). The `unknown` makes sure you don’t forget a check.


One advantage is that unknown makes it clear at a glance where the unsafe cast is happening, and forces devs to do it in a reasonable place (rather than letting `any` propagate through the code accidentally).

Personally though, I think vscode should have a feature to automatically highlight `any` variables is TS code, which would serve roughly the same purpose.

As another commenter mentioned, this is much better when using something like Zod to do the cast safely.


It depends on your setup. I've worked with a tslint setup that treats `any` as an error, only accepting `unknown` (or in bad situations, `never`) for stuff like Javascript code.

If you treat `unknown` as C's `void*`, then you're not getting much out of your type checks.


In that case what's the point of any type system at all? Any type can be cast to another. If you don't just arbitrarily cast whenever you like, unknown is extremely useful to remind you to type check or parse before you use a value.


If you have tight interfaces that fully encapsulate the use of any, you're fine. If you want to return `any` or pass it around outside of an insulated zone, you're gonna have a bad time.

`any` is a contagious/viral construct where anything it touches could become `any`. And then you spiral out of control and the whole codebase gets nothing from typescript but gets all the operational overhead.

If your browser doesn't support text fragments, can cmd/ctrl+f for "contagious" and/or "gradual" https://www.typescriptlang.org/docs/handbook/typescript-in-5......

Final note is that you likely shouldn't blindly cast unknown to stuff that you hope it is but instead use type guards to find out what the type is.

Zod is really good at making this ergonomic for most use cases. More niche types using nonprimitives or uncopyable attributes require hand rolled type guards :/

Type guards also update the type of your unknown var after it passes through them if you gate scopes with them if(isX(y)) {...}

Additional guards will merge attributes onto the type


`any` is infectious, a blackhole for type safety. You can accidentally pass values with `any` all over the place and TS won't complain, but it also won't catch any mistakes you make. `unknown` is much more restrictive and you can't accidentally pass those values around without refining the type, it's much safer.


Probably to make you parse it with a schema validation library, which will throw if it fails to do so.


Using type predicates. But yes that can be tedious.


No, I'm going to parse it with zod and make sure it is what I think it is.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: