microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
99.77k stars 12.35k forks source link

import ConstJson from './config.json' as const; #32063

Open slorber opened 5 years ago

slorber commented 5 years ago

Search Terms

json const assertion import

Suggestion

The ability to get const types from a json configuration file.

IE if the json is:

{
  appLocales: ["FR","BE"]
}

I want to import the json and get the type {appLocales: "FR" | "BE"} instead of string

Use Cases

Current approach gives a too broad type string. I understand it may make sense as a default, but having the possibility to import a narrower type would be helpful: it would permit me to avoid maintaining both a runtime locale list + a union type that contains the values that are already in the list, ensuring my type and my runtime values are in sync.

Checklist

My suggestion meets these guidelines:

Links:

This feature has been mentionned:

DetachHead commented 1 year ago

@j-murata nope. see these two comments above https://github.com/microsoft/TypeScript/issues/32063#issuecomment-1409510267 https://github.com/microsoft/TypeScript/issues/32063#issuecomment-1409556488

PineSongCN commented 1 year ago

@RyanCavanaugh you set this as "Awaiting more feedback" - it has 110 thumbs up and a load of comments from people who would find the feature useful. Can it be considered now or at least the tags changed? Or does it require more people to add to the emoji's ?

Four years have passed.

roninjin10 commented 1 year ago

Would be extremely useful. Without this we need to write a cli tool that generates a .ts file from a json so I can as const it.

matthew-dean commented 1 year ago

Just to comment on this thread, I think importing as const for a JSON is a bit of the wrong direction, since that doesn't really successfully coerce all the types that you might expect into the shape you actually want.

A surprising behavior of satisfies is that it does type coercion, and not just type-checking.

For instance, you may not realize it (or at least I didn't), but these two objects will have two different types:

let foo = {
  variant: 'primary'
}
let bar = {
  variant: 'primary'
} satisfies { variant: 'primary' }

The first object's variant key has a string type. The second object's variant key has a string literal type where primary is the only valid value.

Meaning that following the above with this will cause a TypeScript error:

bar = foo

So, rather than

import myJson from './myJson.json' as const

I think the syntax that actually has the flexibility to solve different problems in this thread would be more like:

import myJson from './myJson.json' with { satisfies: SomeType }

That would / could coerce a type into string or number literals exactly where you want it, and preserve the existing type inference where you didn't.

OR: Alternatively: it would be great if these two things had the same behavior, and would would require no additional syntax (like with the with keyword):

const value = {
  variant: 'primary'
} satisfies SomeType
import value from 'value.json'
value satisfies SomeType
RyanCavanaugh commented 1 year ago

Sidenote: Please stop noting the linear passage of time. Language features are not handled "first in first out".

somebody1234 commented 1 year ago

tl;dr: "please stop noting the linear passage of time" is the opposite of constructive feedback.

however, they are expected to be responded to in a timely fashion, rather than having no official feedback whatsoever. and it's not even like it'd be that difficult to constructively give feedback:

i personally think it's a little silly to address the two comments that note the passage of time and completely ignore the other 95%.

it's also worth noting that saying "please don't note the linear passage of time" will not stop people from saying that

RyanCavanaugh commented 1 year ago

There are thousands of open suggestions, and I am doing the best I can to leave comments explaining why certain features haven't made the iteration plans, as well as explicitly rejecting certain features that have been opened for a long time. You can see this in places like minification, regex types, typed exceptions, etc.. I'm doing the best I can and I'm asking people to just act in a way that makes it easier for me to do that, which I think is aligned with everyone's interests since posting the comments about how long something has been open doesn't help anyone.

Part of the difficulty in writing those comments is honestly the sheer volume of nonspecific feedback ("Any updates?" "This is has been open for N years") that doesn't contribute meaningfully to the discussion, and leads people to make duplicative comments because things that have already been said are hiding behind "Load [n] more comments" blocks.

somebody1234 commented 1 year ago

tl;dr: i don't think there's a "sheer volume" of nonspecific feedback here - at the very least it doesn't make things "difficult" in this thread specifically. as i have already pointed out there are only two topics about the amount of time passed. there are (currently) 40 hidden items. even completely removing said comments would reduce the amount of messages behind "load [n] more comments" blocks by 5% which is an insignificant fraction.

tl;dr 2: as mentioned below, this is the tenth most upvoted suggestion, hence i believe it deserves at least two minutes spent writing constructive feedback. this averages out to 30 seconds per year this issue has been open which i do not think is an unreasonable amount of time to spend.

sure, that's fair. regardless:

sebastian-fredriksson-bernholtz commented 1 year ago

@matthew-dean I think you're missing a few very important points. Most importantly, satisfies and as const fulfil very different purposes. The purpose of as const is to assert (cast/coerce) the type of a literal value into the narrowest possible type. satisfies is for validating that the type of a value is a subtype of another type without making any type assertion (cast/coercion)!

Lets look at satisfies in some detail:

  1. It's better to discuss this issue in terms of 'type narrowing" and "type widening" rather than the much broader "type coercion".
  2. satisfies does not technically do any type coercion - or type narrowing/widening for that matter. The entire purpose of the satisfies operator is to verify that the type of a value conforms - is a subtype - to another wider type without widening the type of the value . It most definitely is not intended to do type narrowing!

So if satisfies doesn't do type narrowing, what is going on in your example?

  1. A literal value in TypeScript - unlike variables, etc - isn't really of a single specific TypeScript type. While it is of a specific runtime type - and the runtime type corresponds to a TypeScript type - the value can be interpreted as either the primitive type or the literal type. Eg. The literal string "hello" is of runtime type string, but can be interpreted as both the TypeScript primitive type string and the string literal type "hello".
  2. When a variable is assigned to without a type annotation, TypeScript infers that the variable is of the same type as the type of what it's being assigned. Without any further information TypeScript will interpret all literal values as their primitive types rather than their literal types.
  3. However, if a literal value satisfies a literal type, Typescript can only interpret it as the literal type, since interpreting it as the primitive type would fail the type verification.

So now we can explain your example despite satisfies not technically doing any type casting:

let foo = { variant: "primary" } 
// can be interpreted as either primitive or literal type, is interpreted as primitive

let bar = { variant: "primary" } satisfies { variant: "primary" } 
// invalid if interpreted as primitive  - 'string' not assignable to '"primary"' - is interpreted as literal

I'd be interested in knowing how you think as const

doesn't really successfully coerce all the types that you might expect into the shape you actually want.

The only thing that I could think of is that you don't want it to be readonly. However, with the proposed as const import and the existing satisfies operator you could do whatever you want with the structure:

const foo = {
  variant: "primary",
  invariant: "green"
} as const
// equivalent of importing as const, type is { readonly variant: "primary"; readonly invariant: "green"; }

const bar = {
  ...foo,
  variant: "secondary",
  additional: "stuff"
} satisfies {invariant: "green", variant: string, additional: "stuff"}
// bar is of type  { variant: string; invariant: "green"; additional: "stuff" }

If you're talking about something similar to what the original commenter had in mind it can also be achieved through derivation as @parzhitsky mentioned:

const foo = {
  appLocales: ["FR","BE"]
} as const
// type { readonly appLocales: readonly ["FR", "BE"]; }

type Locale =typeof foo["appLocales"][number]
// type "FR" | "BE"

const locale: Locale = "FR"
matthew-dean commented 1 year ago

@sebastian-fredriksson-bernholtz

I would buy your argument that satisfies isn't doing any type coercion except for two points:

let foo = 'one' satisfies 'one' | 'two'

In the above example, foo does satisfy the type given, yet the type of foo is still string. If one is a value within an object, the behavior flip-flops, and type coercion occurs.

Second, in the TypeScript documentation, it explicitly says this:

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

Using the satisfies operator changes the resulting type of the expression (sometimes). The documentation is false, or the behavior is a bug.

That said, I filed this issue which has been kept open as a separate problem / solution, so maybe one of these ideas will prevail.

Personally, I don't like that using satisfies changes the type outcome (and does so inconsistently), contrary to the documentation, so I dunno, maybe as const is better and satisfies should be fixed. 🤷‍♂️

sebastian-fredriksson-bernholtz commented 1 year ago

@matthew-dean

There is still no "type coercion", only type inference. And while my explanation isn't entirely correct for your example, it becomes so with a slight change of wording from

can only interpret it as the literal type

to

is "better" to interpret it as the literal type

So, what is going on in your example?

TypeScript is doing it's best trying to infer what type foo should be from the information you're providing and being as unobtrusive as possible. Since foo is a let - as opposed to const - it can infer that you intend foo to change. Hence, it's "better" to interpret 'one' as string than as 'one'.

I do think it's bug though and worth an issue to request that foo is inferred to be 'one' | 'two' rather than string in your case, but it definitely shouldn't be inferred as 'one'.

The documentation could be clearer, but if you think of literal values as not having a specific type, the documentation isn't incorrect. satisfies does not change the type of the expression, the expression is not of a (specific) type. satisfies simply provides context for how to infer the type when the type is yet to be determined.

As you can see from this simple example satisfies has no impact on the inferred type if the value "is of a specific type":

let foo = 'one' as 'one' satisfies 'one' | 'two'
// foo is type 'one' because 'one' is of type 'one'

I'm still interested in what you think is the problem with as const assertion?

matthew-dean commented 1 year ago

satisfies does not change the type of the expression, the expression is not of a (specific) type. satisfies simply provides context for how to infer the type when the type is yet to be determined.

To me, that's a distinction without a difference. "without changing the resulting type of that expression" is the key phrase in the documentation e.g. the resulting type should be identical whether satisfies is present or not. Calling it "inference" vs coercion is also just semantics in this instance. It would have been one type, but it is not, because of the satisfies keyword.

To be clear, I'm fine if satisfies has this behavior if it is consistent and if it's noted in the documentation that satisfies does have an influence on the "resulting type". That's fair, isn't it?

As to this:

I'm still interested in what you think is the problem with as const assertion?

There were a few comments in this thread about how exactly as const should behave. I think it really depends on how TypeScript restricts how the inferred type can be used.

I guess if it imports JSON as the narrowest type possible, that's fine (if that means it can be passed to functions or assigned to variables expecting broader types), but even in testing as const in the TypeScript playground, it doesn't infer types in the way I expect.

Take this for example:

const foo = [
  {
    variant: 'primary',
    num: 1
  },
  {
    variant: 'secondary',
    num: 2
  }
] as const

This results in this type:

const foo: readonly [{
    readonly variant: "primary";
    readonly num: 1;
}, {
    readonly variant: "secondary";
    readonly num: 2;
}]

But what I really wanted was:

type Foo = Array<{
  variant: 'primary' | 'secondary'
  num: number
}>

or even

type Foo = Array<{
  variant: 'primary' | 'secondary'
  num: 1 | 2
}>

There's no real way to tell as const what to do. Within the code, as we've been discussing, the only way to coerce or "help infer", whatever you want to call it, along with type-check the object is with satisfies.

If I do:

const foo = [
  {
    variant: 'primary',
    num: 1
  },
  {
    variant: 'secondary',
    num: 2
  }
] satisfies Array<{ variant: 'primary' | 'secondary', num: number }>

.....well, actually we still don't quite get there, because it still doesn't infer the type that we explicitly gave it, even though it correctly satisfies it. 🙃 (Honestly, the more I experiment with satisfies in relation to this and related issues, the more I'm confused with how it works or is supposed to work.)

We get a type that is:

const foo: ({
    variant: "primary";
    num: number;
} | {
    variant: "secondary";
    num: number;
})[]

...which is clunky and not what I meant for it to infer, but okay. (And doesn't infer the type similarly to the non-array example, so I'm just more confused.) But still, the point is that I can nudge the TypeScript inference using satisfies in a way I cannot using as const. And, more importantly, I can type-check using satisfies while I can't with as const.

I think though, to be fair, using as const might be "good enough" for most scenarios? And maybe could be further inferred or coerced using some kind of advanced type construct. I'm not sure I would put as const on the import syntax, instead of extending the new with syntax for imports, but maybe it's fine? 🤷‍♂️

osdiab commented 1 year ago

This is all stimulating and nice but seems pretty unrelated to the original issue and you’re buzzing subscribers emails without actually indicating progress towards the original goal. Maybe better to have that discussion in a separate GitHub issue/discussion if the above is very important to you?

mmkal commented 1 year ago

Would this be easier to implement if it didn't require syntax? What if you could just set this for all json modules in tsconfig:

{
  "compilerOptions": {
    "resolveJsonModule": "const"
  }
}

i.e. change the type of resolveJsonModule from boolean to boolean | 'const'.

I'd be happy enough with that - in the rare case that resolving as const is problematic, it's much easier to go from high- to low-information than the other way round.

roninjin10 commented 1 year ago

Resolving all jsonMOdules as const is dangerous for performance IMO. It's easy for one engineer to turn that on while another one adds an extremely large json array that requires type checking linearly everytime it's used

matthew-dean commented 1 year ago

@roninjin10 I don't see how. The JSON file would still be inferring X type based on Y value. So the actual process involved would be the same. In fact, one could argue that it should take less time because with some values, it can directly assign the type without widening, but that's entirely dependant on TS internals.

Related: has the argument been made in this thread that as const should maybe be the default type for JSON, such that as const shouldn't be needed? I assume so, but maybe I missed it. I'm wondering what the downside would be for importing as a constant value. If you think about it, file contents data should already be immutable. I guess there would be a rare risk of breaking code somewhere though.

Peeja commented 1 year ago

@matthew-dean Yes, to infer the type, but once it's inferred, if it's a gigantic union of string literals instead of string (I believe) it would be expensive to throw around the code after it's been inferred.

OTOH, if it's not expensive or otherwise concerning, it seems to me like it would be the ideal default.

slorber commented 11 months ago

Since ES Import Attributes (Stage 3) are going to be worked on for TypeScript 5.3, I was wondering if it's not a viable option to import JSON as const?

import data from "./data.json" with { type: "json-const" };

I'm not sure about what to use for the type though.

Edit: agree with @matthew-dean here, {type: "json", const: true} looks better.

matthew-dean commented 11 months ago

@slorber I think altering the type shouldn't be allowed and is counter-intuitive. However, because this is a plain object, I could see TypeScript doing something like this:

import data from "./data.json" with { type: "json", const: true };

That way, it doesn't "alter" the import statement itself, just the with object, which can already extend the import statement.

Malix-off commented 8 months ago

Would const assertion after declaration be considered too?

MatthD commented 8 months ago

Hello everyone, so ATM what is the right solution (if it exist) to match json import with some literals and have the codebase happy ?

parzhitsky commented 8 months ago

@MatthD If you must have a JSON file and not do any type casting, then there's no solution currently, you'll have to use whatever typings are given to you. Alternatively, you can define a variable and cast it to the necessary type using satisfies or as const approach, but that means having at least some duplication.

slorber commented 8 months ago

Not a drop-in workaround for json const, but you can type a json file manually.

@mattpocock has a little article on it: https://www.totaltypescript.com/override-the-type-of-a-json-file

CleanShot 2023-12-08 at 13 57 42@2x
shamrin commented 5 months ago

@slorber Unfortunately .d.json.ts definition makes TypeScript ignore the content of JSON file. Now JSON can have a shape that does not match its type and one would never notice.

CodeFromAnywhere commented 2 months ago

Hi I found this issue since I think it would be a great addition to infer Types from Json Schemas! See https://github.com/ThomasAribart/json-schema-to-ts/issues/200

Happy to help if someone can point me in the right direction.