Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

satisfies should work with imported / referenced values #54488

Open
5 tasks done
matthew-dean opened this issue Jun 1, 2023 · 25 comments
Open
5 tasks done

satisfies should work with imported / referenced values #54488

matthew-dean opened this issue Jun 1, 2023 · 25 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@matthew-dean
Copy link

matthew-dean commented Jun 1, 2023

Suggestion

Right now, satisfies works on values and object literals right after they're declared. However, there are use cases when satisfies can't be used immediately, and the value is imported, such as JSON files.

🔍 Search Terms

JSON, typed JSON, satisfies JSON

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

In short, I'd like to be able to do this:

import book from 'book.json'

const foo = book satisfies BookResource

or, alternatively:

import book from 'book.json' with { satisfies: BookResource }

and have it work equal to:

const book = {
  // book fields
} satisifies BookResource

📃 Motivating Example

I have this structure in my book data JSON file:

{
  "data": [
    {
      "type": "book",
      "baseCode": "394b76ce-3ad9-4c83-b21d-d5eab7962249",
      "id": "394b76ce-3ad9-4c83-b21d-d5eab7962249",
      "url": "http://github.com/donec/dapibus/duis/at.jpg",
      "originalUrl": "http://qq.com/ante/ipsum/primis.json",
      "imageUrl": "http://dummyimage.com/246x100.png/5fa2dd/ffffff",
      "retailer": "Amazon",
      "title": "Dead End Drive-In",
      "author": "Vlad Cutmore"
    },
    {
      // more of the same schema
    }
  ]
}

And I have a type (interface) like:

interface BookResource {
  id?: string
  type: 'book'
  baseCode: string
  imageUrl: string
  title: string
  author: string
  originalUrl: string
  retailer: string
  url: string
}

(Note, in this scenario, the source data MUST be JSON.)

I want to make sure that if anyone modifies the JSON, they get a type error. However, doing the following results in an error:

import books from 'books.json'
// ...
const bookData = books.data satisfies BookResource[] // error - "Type {...} is not assignable to BookResource"

Now, I wasn't sure if this should be marked as a bug or feature request, because this works:

const blah = {
  type: 'book',
  baseCode: '394b76ce-3ad9-4c83-b21d-d5eab7962249',
  id: '394b76ce-3ad9-4c83-b21d-d5eab7962249',
  url: 'http://github.com/donec/dapibus/duis/at.jpg',
  originalUrl: 'http://qq.com/ante/ipsum/primis.json',
  imageUrl: 'http://dummyimage.com/246x100.png/5fa2dd/ffffff',
  retailer: 'Amazon',
  title: 'Dead End Drive-In',
  author: 'Vlad Cutmore'
} satisfies BookResource

In other words, the exact same value data, when applied with the satisfies keyword, causes an error depending on where the value is being checked, even though it's the same value.

What seems to be the case is that satisfies gently coerces the type (specifically to "book" instead of string) in the latter example, but fails to do so when the value is imported.

💻 Use Cases

Currently, I want to use this for JSON, but note that there are other cases where you might want to "check" and gently nudge the type at compile time the exact same way that happens with satisfies at assignment time.

Because, similar to the above example, this also causes an error:

const foo = {
  type: 'book',
  baseCode: '394b76ce-3ad9-4c83-b21d-d5eab7962249',
  id: '394b76ce-3ad9-4c83-b21d-d5eab7962249',
  url: 'http://github.com/donec/dapibus/duis/at.jpg',
  originalUrl: 'http://qq.com/ante/ipsum/primis.json',
  imageUrl: 'http://dummyimage.com/246x100.png/5fa2dd/ffffff',
  retailer: 'Amazon',
  title: 'Dead End Drive-In',
  author: 'Vlad Cutmore'
}
const bar = foo satisfies BookResource

In TypeScript / JavaScript terms, these should be equivalent statements, and TypeScript should be able to

  1. say that the value of foo satisfies the type of BookResource
  2. coerce bar to be of type: "book" vs type: string, just as it would if satisfies immediately followed the value.

1st note: obviously I could just use as BookResource instead of satisfies BookResource, but of course that bypasses the type-checker entirely, and defeats the purpose. Then, the JSON could have any data / shape whatsoever, with no errors provided.

2nd note: I can also sorta get there with satisfies Array<Omit<BookResource, 'type'> & { type: string }> but that's kind of gross, and I want to enforce that the type truly is "book" and not just a string. JSON type inference is helpful but it isn't perfect, and currently there's no way to actual tell it how to interpret string literal types.

@RyanCavanaugh
Copy link
Member

It seems like this does what you want? What falls short here?

import book from 'book.json';
book satisfies BookResource;

I don't think we want to mix-and-match TS and ES syntax in import any more than we have to, especially when the equivalent desugared code is extremely similar.

@jcalz
Copy link
Contributor

jcalz commented Jun 1, 2023

I think this is probably morally a duplicate of #32063 (to import a json module "as const")

(@RyanCavanaugh the type property needs to be the string literal type "book" in the example, but that won't happen)

@matthew-dean
Copy link
Author

matthew-dean commented Jun 1, 2023

@RyanCavanaugh Here is a reduced test case: https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgEIHt0GsAqBPABxQG8AoZZMQiALmQHIAjTLe0gX1NIXRAGcwyGJmQBeZGQpUidJizadSw9Mj5wwwPjGAQ+aFviJcgA

X satisfies Y as a statement would be great if it worked, but it doesn't.

@matthew-dean
Copy link
Author

@jcalz

I think this is probably morally a duplicate of

It's partly the same core problem / bug / issue, I guess you could say, which is that TypeScript's type inference of object shapes is often useful and also, often wrong, and there's no real option to coerce the types, except to completely override the type into any possible invalid interpretation of the type, which doesn't solve anything.

The surprising discovery that I don't think was covered in the documentation write-ups on satisfies is that it actually changes / coerces the type, instead of being simply a type of type check assertion. Since that is part of the behavior of satisfies, then IMO it makes more sense to be able to coerce (and check) the type using satisfies on any value, rather than importing as const because that's incredibly limited.

So IMO the linked issue is talking about a very narrow part of the problem, and discussing a solution that won't actually address that problem fully.

@RyanCavanaugh
Copy link
Member

often wrong

By "wrong" here do you just mean "is less specific than I would have like to be inferred" ? Or is there an actual "it says it's a string but it's actually a number?" instance?

@matthew-dean
Copy link
Author

@RyanCavanaugh Well, what I mean is more that the satisfies statement:

  1. says an object doesn't conform to an interface when it does, depending on where it is declared or checked
  2. coerces a value into the satisfies type in some circumstances and rejects it as a mis-matched type in others

The fact that satisfies coerces the type at all was counter-intuitive to me, because I thought it was just a syntax for validating the type. But because it has that behavior, it would be useful for coercing the type of a JSON file to the expected type.

@RyanCavanaugh
Copy link
Member

I mean, sure, but you wouldn't want it to not do that, because then a bunch of assertions that you would want to succeed would fail.

@matthew-dean
Copy link
Author

matthew-dean commented Jun 1, 2023

@RyanCavanaugh Correct. I'm not suggesting changing satisfies existing behavior. I'm suggesting keeping this coercion consistent with any other value, referenced or imported, so extending satisfies to any value, not just a value at the original definition time.

@matthew-dean
Copy link
Author

i.e. I would be overjoyed if this worked:

import book from 'book.json';
book satisfies BookResource;

If that's all it took for book to be coerced to BookResource, just as it would be inline, that would be wonderful.

@matthew-dean
Copy link
Author

@RyanCavanaugh @jcalz We can close this if #32063 tracks this better. I just didn't want to completely derail if people are attached to the idea of as const (although I would argue that wouldn't completely solve the problem).

@RyanCavanaugh
Copy link
Member

I think these are separable and both valuable. The scenario of a middle ground between the entire blob being const, which is often way too specific, and just getting the inferred type, which might not be as specific as you want, makes sense.

@jcalz
Copy link
Contributor

jcalz commented Jun 2, 2023

If we had as const then you could import the json that way and widen to whatever type you wanted in a different variable. (Well, you would, if readonly arrays were actually subtypes of mutable arrays and not the other way around.) That might be less ergonomic than satisfies for this use case, but it would at least be possible. Right now we only have the inferred type, or something you just claim is the type from a .json.d.ts file as mentioned in #49703 (comment)

Oh, that reminds me that this could also be "morally equivalent" to #52994 (which is clunkier than this suggestion, imo).

I don't know that any of these should be closed as duplicates of each other, but I think they are all circling around the same unmet need to allow something like a type annotation for imported json. I'd hate for those who want that to split their vote among particular flavors of the feature, if it lowered the likelihood of any of them being implemented.

@matthew-dean
Copy link
Author

I think they are all circling around the same unmet need to allow something like a type annotation for imported jso

In this case, JSON is the primary use case, yes, but there are actually a number of related TypeScript issues of wanting to narrow the type within a block scope without sending it through an assertion function, or when a type inferred through an object value satisfies two types but TypeScript insists it doesn't, because it's already set the object's inferred type in stone.

A possible solution, then, might also be simply that TypeScript:

  1. Infers a set of "type candidates" which it can narrow through satisfies, and/or
  2. Doesn't throw an error of a mis-matched type when it's an inferred type and not an explicit one, and/or
  3. TypeScript does the above only with inferred JSON, since type annotation is (currently) impossible.

#2 is probably a little too complex and might have unexpected consequences, but it violates the Law of Least Surprise at least a little bit, when TypeScript says that an object with a string literal value cannot be assigned to a variable or passed to a function that's expecting the type of an object with that very same string literal. i.e. the concrete value can match the expected type perfectly, which should be passable, except that the inferred type does not match. So the problem is both the nature of inference, and the nature of type checks.

@matthew-dean
Copy link
Author

matthew-dean commented Jun 3, 2023

Really, this issue is more fundamental than objects or JSON, as demonstrated by this playground example, in which the behavior of satisfies is (IMO) highly inconsistent (regarding whether or not it coerces types). But also the variables of first and second should be passable, because their values are statically analyzable (otherwise a type of string wouldn't have been inferred in the first place).

So, in addition to this issue, which is like a feature request, I would suggest there's also a satisfies bug, in which it sometimes coerces a type, and sometimes doesn't.

Also, can I point out that the TypeScript documentation has this to say:

The new satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression

That was how I understood satisfies, but that statement is demonstrably false. It does change the resulting type of the expression (but only sometimes!), as demonstrated in multiple examples here. So I'm not sure if the behavior of satisfies should be expanded (to all values) OR it should be "corrected" (to not coerce types) OR if the documentation should be changed. I'm really not sure what satisfies is supposed to do, because whatever it does do, it does inconsistently.

@jcalz
Copy link
Contributor

jcalz commented Jun 3, 2023

#47920 is a big discussion but it explains that one intended use of satisfies is to provide contextual typing, so it definitely can affect "the resulting type of the expression". The sentence you object to in the handbook unfortunately ignores this; I presume the point there was to distinguish it from type annotations, which almost always end up discarding more specific information from the initializer.

But at no point was satisfies intended to retroactively change the type of some existing variable. Contextual typing doesn't propagate backwards indefinitely. So

const x = [1, 2] satisfies [number, number]; // okay

is the intended use case, and is fundamentally different from

const x = [1, 2];
x satisfies [number, number]; // nope

where instead of providing context for the value [1, 2], we're apparently trying to do so for the variable x, which is already "set in stone" as you say. As far as I know there's no mechanism anywhere in TypeScript that works like that. You can't re-annotate after the fact; you can't as const after the fact; you can't satisfies after the fact: it's just too late. I don't imagine this will change (but I've been wrong before so 🤷‍♂️; it would be nice to hear some official word).

So I could see any of

import book from 'book.json' with { satisfies: BookResource };
import book satisfies BookResource from 'book.json';
import book from 'book.json' satisfies BookResource;

being implemented since you're declaring the variable in the same statement with your desired type, but

import book from 'book.json'; book satisfies BookResource

doesn't look feasible.

@matthew-dean
Copy link
Author

@jcalz

Thanks for the additional info. I do hope the documentation will be reworded.

One thing, this:

let x = [1, 2] satisfies [1, 2]

Results in a type of x of [1, 2], with let or const, BUT

let x = 1 satisfies 1 | 2

...results in a type of number.

I wonder now if this was intentional? Maybe trying to guess developer intent with satisfies? I mean, to me, the developer wants to make sure x stays within 1 | 2, but maybe the TS team assumed then they would write:

let x: 1 | 2 = 1

I dunno, still trying to wrap my head around the intended behavior.

@sebastian-fredriksson-bernholtz
Copy link

@matthew-dean
as const assertion still seems to fulfil all of your requirements so far, given the slight inconvenience of dealing with readonly array that @jcalz mentioned.

In response to your examples in #32063 (comment), if all you want to do is to validate that your JSON conforms to a certain type, you can just use satisfies operator on the as const import.

type FooA = Array<{ variant: "primary" | "secondary"; num: number }>
type FooB = Array<{ variant: "primary" | "secondary"; num: 1 | 2 }>

const foo = [
	{ variant: "primary", num: 1 },
	{ variant: "secondary", num: 2 },
] as const satisfies Readonly<FooA> satisfies Readonly<FooB>

If the resulting type is too strict for you, i.e. you don't only want to validate the JSON, but you also want to make changes to it according to your defined type, you can easily achieve that as well:

const fooA: FooA = [...foo]
const fooB: FooB = [...foo]

Notice my usage of type annotations over the satisfies operator. Type inference, type annotations, satisfies operator, and as const assertion all have different - well defined - purposes. If you want a variable to be of a certain type, that is what type annotations are for. Type inference is just that, "inference". TypeScript cannot read your mind about what type you think a variable should be. It simply makes a best guess, given the context, as a convenience so that you don't have to annotate everything manually.

The purpose of the satisfies operator - as I understand and would possibly suggest to reword it - is to

validate that the type of an expression matches some [comparison] type, without [widening] the resulting type of that expression

In the general case (where the expression is of a type) that statement is equivalent to the existing one, because "changing" a type can only mean

  1. "narrowing",
  2. "widening", or
  3. changing to non-matching type

Given the implicit understanding that it won't change the resulting type to one that does not match the comparison type, and that it won't arbitrarily narrow the type of the expression further (the type of the expression must already be "narrower" to match the comparison type), the only thing that "changing" could mean is "widening". Hence the statements are equivalent.

The special cases where the statements are not equivalent are when the expression doesn't actually "have a type". At this point I think it's worth pointing out that TypeScript works on types, not values. This seems to be where much of your confusion stems from.

let first = 'one' // type "string" is inferred
let second = 'one' satisfies 'one' | 'two' // type "string" is inferred
let third: 'one' | 'two' = 'two' // type "'one' | 'two'"
third = first // error because TypeScript works on the type of "first" ("string"), the value ('one') is not considered

function tryValues(val: 'one' | 'two') {
  console.log(val)
}
tryValues(first) // error because TypeScript works on the type of "first" ("string"), the value ('one') is not considered
tryValues(second) // error because TypeScript works on the type of "second" ("string"), the value ('one') is not considered

let demoCoercion = {
  something: 'one'
} satisfies { something: 'one' | 'two' } // type "{ something: 'one' }" is inferred

tryValues(demoCoercion.something) // works because type of "something" is "'one'", the value is not considered

import book from "book.json" // say that book has value { author:  "matthew" }, then book will be of type "{ author: string }"
const foo = book satisfies { author: "matthew" } // error because TypeScript works on the type of "book" ("{ author: string }"), the value ({ author:  "matthew" }) is not considered

As mentioned, while literal values have a runtime type and there are corresponding TypeScript types for those runtime types, the type of literal values (as opposed to TypeScript identifiers) is not really defined (I guess they could also be considered to be the literal type).

type T1 = typeof ({
	variant: "primary",
	num: 1,
}) // error: identifier expected

type T2 = typeof "primary" // error identifier expected

let t1 = "primary" // t1 is inferred to be of type "string" but that is NOT the type of "primary", as we can see on the next line
let t2: "primary" = "primary" // this would give error if type of "primary" was "string"

Now we are getting closer to explain some of the inconsistencies with the satisfies operator. They are all related to applying satisfies on a value. However, since TypeScript only works on types, and the satisfies operator "validates that the type of an expression matches some type", it's not really defined what happens when you apply it to an expression with undefined type. Alternatively, it should leave the type of expression as undefined.

Here it's also important to distinguish between the type of the expression, and the type of the variable the expression is assigned to. As shown with t1 above, TypeScript infers the type of the variable when the type of the expression (the value) is not defined (alternatively the literal type). None of the examples has actually shown that the type of the expression has changed, only that the inferred type of the variable you assign the expression depends on the satisfies operator. This does not contradict the statement about how satisfies operates. The type of the expression might still be considered undefined - or the literal type - for all that we have seen.

You're not wrong though, there are inconsistencies in how satisfies affects inference when assigning literal values, although from what I've seen so far, they are usually reasonable guesses from the context. I do think it could be improved by never inferring a type that does not itself match the comparison type, i.e. it should widen the type to the widest type that matches all comparison types in the expression.

There are also cases where satisfies inarguably changes the type of the expression, specifically for object literals

let t3 = "test" satisfies string satisfies "test" // no error, i.e the resulting type from "satisfies string" is not "string", it must still be undefined or literal type
let t4 = { a: "test" } satisfies { a: string } satisfies {a: "test"} // error 'string' not assignable to '"test"', ie the resulting type is defined as { a: string }, not undefined or literal type

All of the code has been tested in TypeScript version 5.0.4 (VSCode) and 5.1.3 (TS Playground).

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jun 5, 2023

Have folks read the very long discussion about this behavior in the original satisfies thread? #47920

@matthew-dean
Copy link
Author

@RyanCavanaugh Ah I see. A number of people objected against contextual typing for reasons of confusion that I encountered, and you argued for satisfies T as T as a safe way to cast to exactly what you want if satisfies doesn't contextually type in a way you expect. satisfies T as T seems so insanely useful that I wish there was simplified syntactic sugar for it. It probably at least deserves it's own section in the documentation, because it solves a number of things immediately but it's not immediately intuitive (since people are often thinking of swapping out as and replacing with satisfies, and not both).

@matthew-dean
Copy link
Author

matthew-dean commented Jun 5, 2023

@sebastian-fredriksson-bernholtz

as const assertion still seems to fulfil all of your requirements so far

Not really. To add more context, let me make it more clear. I refactored some sample data files from .ts to .json, because they were going to be shared / maintained by both frontend and backend developers.

In doing so, I, of course, had to remove the satisfies keyword on the dataset, which I was using to make sure the sample data adhered to the defined data contract, and if someone changed something that didn't, OR they changed the data contract type, then we would get a TypeScript error during type check of the PR.

So now we have data with no contract. as const would simply import the data as-is, but that doesn't help us at all. I could probably (maybe?) do something like define a variable with the data contract type, and assign it to the JSON data, and see if there's a type error, but of course that leaves a pointless runtime assignment. Maybe not a big deal, but I was just hoping for a way to type check (and contextually type) JSON in the same way I was using before. as const would simply narrow the type, but that still doesn't tell me if the type it narrows to is valid, while satisfies does.

EDIT: I suppose the other thing I could do is have a "dummy" file that tries to assign or pass the JSON to a narrowly-defined type, which doesn't get bundled into the runtime? It just seems like a clumsy workaround. 🤷‍♂️

@RyanCavanaugh
Copy link
Member

Something I was curious about was the nature of these JSON files. It seems like you have some schema for these, obviously, since you have some type to write down. Is what you actually need a thing you can run during your build pipeline to make sure that the JSON file matches the .d.ts, and it would just be convenient if that thing was tsc? Or are there separate things, like the schema is actually a super type of the type that you want to have at compile time and you're using satisfies to narrow it down?

@matthew-dean
Copy link
Author

@RyanCavanaugh In our case, if I understand your question correctly, the type in the JSON file should entirely match the defined type, which defines a REST response object. Right now we're manually defining those types in TypeScript, but we do plan to use a tool (ServiceStack) to export those TypeScript types from their C# data contract definition.

So, at minimum, we do want to type-check them at compile time. And its not the worst outcome if TSC is the place that catches those errors. But from a DX point of view, like many devs, most of my interactions with TypeScript / types happen in VSCode, and it would be nice to immediately see an error with a mis-matched type, before running tsc (or, in our case, vue-tsc). I wasn't sure if you were implying that a .d.ts file would do that? My perception is that .d.ts is a little like using as T on an object (as in, overrides the type of an export?), but maybe that's a mis-perception.

@sebastian-fredriksson-bernholtz
Copy link

@matthew-dean
Maybe I'm missing something but I'm not saying you should remove satisfies I'm saying that if you use it with as const you can use it as you originally wanted.

as const satisfies T is what I'm doing in all of the examples.

// narrows type, eg type { author: readonly "matthew" } instead of type { author: string} in original
import book from 'book.json' as const 

// eg type BookResource = { author: "matthew" | "seb" }
// no longer any error after type narrowing through as const,
// contract is being validated
const foo = book satisfies BookResource // type { author: readonly "matthew" } 

const bar: BookResource = book // type  { author: "matthew" | "seb" }

If you really don't want the assignment (I don't see why that is such a concern - is it gigantic?) you might possibly be able to do:

import book from 'book.json' as const
book satisfies BookResource // I don't think this has any effect except typechecking

Or you can do it inline where it's being used

import book from 'book.json' as const

const myFunc = (myBook: BookResource) => {}

myFunc(book) // would throw error if book does not match BookResource

@RyanCavanaugh
Copy link
Member

I guess I'm thinking about a bunch of different scenarios

Scenario 1: You auto-generate, or hand-edit, a single JSON file, and want to ensure that its type matches a predefined contract. You don't want to use as const because the actual implied domain of the types isn't uniformly literals, or because you might want some discriminated unions in there that can't be inferred from the shape.

In this scenario, you don't need tsc to be doing the checking (you can write a tool to do this out-of-band), but it'd be nice

Scenario 2: You have a variety of JSON files that might match some variety of top-level schema, e.g. you might have a few different config file shapes and use them interchangeably to describe some UI. You want to import A from "a.json" satisfies T and import B from "b.json" satisfies T and have A and B have different shapes depending on what was actually in the file.

In this scenario, you really do need a type hint on the import. But this scenario seems sort of niche.

Scenario 3: You actually just need a checked type annotation, as if you had written

import x from "./foo.json";
const y: T = x;

or

import x from "./foo.json" as const;
const y: T = x;

It seems like you're describing Scenario 1 here but it's all sort of adjacent and we'd need to think about which scenarios are adjacent to the request

@matthew-dean
Copy link
Author

matthew-dean commented Jun 5, 2023

@sebastian-fredriksson-bernholtz

Maybe I'm missing something but I'm not saying you should remove satisfies I'm saying that if you use it with as const you can use it as you originally wanted.

Ohhhh okay, thank you for clarifying! Yes, if this just worked without having to define a .d.ts, this would 100% be fine with me and I think would meet requirements, you are correct.

import book from 'book.json' as const
book satisfies BookResource

To @RyanCavanaugh's point, though, there may be some other scenarios where you want contextual typing on the JSON (even if contextual typing was a surprise to me--as it seems like it was worth it in the way satisfies operates). I think you're right that I personally maybe don't need that here, but it seems reasonable that someone might want it on JSON just as much as they would want it on any other defined object.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Jun 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants