Repository navigation
Support overriding the types of a set of JSON filesΒ #49703
Description
Suggestion
π Search Terms
JSON declare module change type resolveJsonModule
β 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
Allow applications to override/declare the imported type of a set of JSON modules.
We seem to be able to declare types of imported modules with declare module, but if the target module ends with .json extension, the declaration does not seem to take effect. For example, this works, with some limitations:
type ResourceId = string & { __ResourceId: void };
declare module "*.locstring" {
const value: { [key: string]: ResourceId };
export default value;
}But this does not work (just because of the .json extension):
type ResourceId = string & { __ResourceId: void };
declare module "*.locstring.json" {
const value: { [key: string]: ResourceId };
export default value;
}π Motivating Example
This feature would allow developers to, at compiler time, use branded string types to identify strings that were loaded from specific JSON files, as opposed to plain strings. The compiler could then detect if functions that expect such branded strings are called with other types of strings.
π» Use Cases
Our application stores strings for localization in *.locstring.json files.
For example, hello.locstring.json might contain:
{ "hello": "Hello World" }Code consumes these strings by importing the locstring file and calling a loc(resourceId: string): string function. For example:
import React from 'react';
import strings from './hello.locstring.json';
export function Hello() {
return <div>{loc(strings.hello)}</div>
}We're using resolveJsonModule, so the strings variable is properly typed and the compiler will detect if you try to access a field that does not exist, like strings.helloWorld. The type of strings.hello comes as string as one would expect from a normal .json file.
This presents a problem - while it is valid to call loc with strings.hello as parameter, it should be invalid to call loc with "Hello World", for example. When multiple functions and parameters are involved, people accidentally end up calling things that look like loc(loc(strings.hello)) and that does not work, because loc expects the parameters to be 'resource ID' (ie, fields from the locstring files), and not any plain string.
The reason for this is that in debug mode, loc is pretty much a pass-through, so it outputs the "localized" string as found in the JSON file. But in retail there is a lookup involved. At build time, the contents of all *locstring.json files are processed and the string we get back from strings.hello ends up being a unique identifier, let's say "a2w" or something like that. When the application runs, it will load the actual set of strings and put them into a map, and then loc will take the given resourceId passed as parameter and lookup the corresponding string in the current language.
In this scenario, passing a random plain string to loc as a parameter is a bug, since it will not exist on the map - or worse, the random string is user input (like a file name) and it just happens to match one of the resource ID and it would be localized to a random string, so that can't happen.
Ideally, we want to detect invalid calls to loc at compile time.
We could use a branded string type to limit the types of strings one can pass to loc, for example:
type ResourceId = string & { __ResourceId: void };
export function loc(resourceId: ResourceId) : string {
return resourceIdToLocalizedStringMap[resourceId];
}Doing so causes every call to loc(string.someId) to be flagged as an error, because the compiler rightfully believes string cannot be assigned to ResourceId.
Trying to tell the compiler that the fields it gets from the *.locstring.json files are in fact ResourceId does not seem to work:
declare module "*.locstring.json" {
const value: { [key: string]: ResourceId };
export default value;
}
// We end up with this, suppose test.locstring.json has two strings in it, Hello and World:
import locStringJson from "./test.locstring.json";
// import from *.locstring.json does have both fields, but still typed as plain strings.
// I wish these would be typed as ResourceId
export const hello: string = locStringJson.Hello;
export const world: string = locStringJson.World;
//export const worldAsResourceId: ResourceId = locStringJson.World; // BAD: Fails with string cannot be assigned to ResourceId
//export const invalid: string = locStringJson.Bad; // GOOD: Correctly fails with 'Property Bad does not exist in type...On the other hand, IF the files were named just .locstring (without the .json extension) then it kinda works, although we lose one thing in the process:
import locStringOnly from "./test.locstring";
// This is not ideal, since our strings are in *.locstring.json files, but it shows there could be some way to do it.
// import from *.locstring has proper type in fields, but since it comes up as a record, one can make typos.
export const hello2: ResourceId = locStringOnly.Hello2;
export const world2: ResourceId = locStringOnly.World2;
export const invalid: string = locStringOnly.Bad; // BAD: Ideally this must fail with 'Property Bad does not exist in type...For us, renaming the files at this point is not feasible because there are several devops processes around them.
The other issue is that, IF the declare module "*.locstring.json" were to work as expected, with that particular definition we would be losing the detection of incorrect field names. That comes from writing simply value: { [key: string]: ResourceId } in the declaration. I'm not sure there's a way to refer to the original type of the JSON.
Bonus points:
Our locstring files can contain multiple strings, and every localizable string must also contain a comment for localization to know how to properly deal with it, with the name derived from the original name. For example, a login.locstring.json file would look like this:
{
"nameLabel": "Name",
"_nameLabel.comment": "Label for the username field in the login screen",
"passwordLabel": "Password",
"_passwordLabel.comment": "Label for the password field in the login screen",
}Ideally only strings.nameLabel and strings.passwordLabel would be valid ResourceId to be passed to loc.
That being said, it is not that big of a deal if the "_id.comment" fields also come up as ResourceId because using them is cumbersome and people don't make that mistake.
So, the two requests in this issue are:
- Can we allow
declare module "*.some_extension.json"to override the type produced by a plain .json file? - Can we write the overriding type in such a way that it will preserve the set of fields the plain .json file would have had, replacing only the types of the fields?
Activity
fpintos commentedon Jun 28, 2022
A few extra comments:
In our codebase, we have several thousand locstring files, and that many more imports of them, so explicitly casting each import would be considered cumbersome.
RyanCavanaugh commentedon Jun 30, 2022
I don't see any immediate blocker here. Wesley Wigham (@weswigham) any concerns with making ambient module declarations apply to json extensions? I would have expected this to work already
This would be a pretty big architectural lift from our end; I'd really rather not. This seems like a place where some pre-build tooling would be appropriate depending on the scenario.
weswigham commentedon Jun 30, 2022
Yeah, I don't know why it doesn't already - probably some weird prioritization logic for json modules.
Definitely. It's what we do here - generate declarations from loc json.
It's probably worth noting that even if ambient pattern declarations don't currently apply to
.jsondocuments, you can write a.json.d.tsthat we will pick up, at least in commonjs (I'm less sure about in modes that require exact extension matches...).muzuiget commentedon Jul 18, 2022
TypeScript infers the JSON types, this behavior is really painful, because sometime JSON files are use as "database-like" code.
for example:
Iooking for something feature disable the JSON type infer:
weswigham commentedon Mar 15, 2023
JSON files can now have types provided for them by an adjacent
.d.json.tsfile - this is different than the details of the proposal in the OP's request, but definitely satisfies the title and is close enough that I'm OK closing this. A new issue can be filed in its' place explaining why the declaration file solution is somehow insufficient, should that be the case~frolic commentedon Aug 9, 2024
Sorry to dig up an old issue, but out of curiosity, is the intent of
.json.d.tsfiles to also take precedence over the types fromresolveJsonModule: true? Because when I am using both, I only ever get types fromresolveJsonModule: true, never from my.json.d.tsfiles (essentially a copy of the JSON withas constat the end).Happy to open an issue + repro for this to demonstrate.
weswigham commentedon Aug 13, 2024
You want
.d.json.ts, not.json.d.ts. The first corresponds to a.jsonfile, the later to a.json.jsfile, which, in the context of cjs automatically adding.jsto your imports, was sometimes close enough, but is not quite the same.frolic commentedon Aug 14, 2024
Thanks! I gave that a try, but TS couldn't seem to find the type declarations for the
.jsonimport like it usually would with.json.d.ts.I enabled
allowArbitraryExtensionsand both TS and JS runtime are happy, but then tsup seems unable to build the DTS files for the project because it stumbles on not knowing what to do with.d.json.ts. I tried:excludeintsconfig.jsonto ignore**/*.d.json.tsfilesloaderintsup.config.tsto treat.d.json.tsastsTo clarify, importing a
.jsonfile that had a corresponding.json.d.tsdoes seem to work whenresolveJsonModuleis disabled. So it seems like the tooling above mostly knows what to do with the declaration files with this naming, but not sure if that's a happy and unintended side effect of something else. And unclear why.json.d.tsfiles are sort of ignored onceresolveJsonModuleis enabled.Anything else I should try? I feel like I am missing something.
weswigham commentedon Aug 14, 2024
I imagine
tsupwas never updated to handleallowArbitraryExtensionscorrectly, then, if it doesn't treat.d.*.tsfiles as declarations correctly (it shouldn't need to build a declaration file - it already is one) - I'd go file an issue on them and see what happens.