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

Export additionalProperties as intersection for complex types #383

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

fabrykowski
Copy link

@fabrykowski fabrykowski commented Apr 21, 2021

A quick fix for #299, #356 and #402.

Given the schema

{
  "title": "AdditionalProperties",
  "type": "object",
  "properties": {
    "foo": {
      "type": "string"
    }
  },
  "additionalProperties": {
    "type": "number"
  }
}

it is rendered by the library as

export interface AdditionalProperties {
  foo?: string;
  [k: string]: number;
}

which results in the compiler error

TS2411: Property 'foo' of type 'string | undefined' is not assignable to string index type 'number'.

This PR renders the schema above as

export type AdditionalProperties = {
  foo?: string;
} & {
  [k: string]: number;
};

which is valid TypeScript.

@@ -5682,6 +5691,142 @@ Generated by [AVA](https://avajs.dev).
}␊
`

## realWorld.jsonschema.js
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea, why this section is moved

@bohjak
Copy link

bohjak commented May 6, 2021

But it doesn't seem to actually fix the problem? Or am I doing something wrong?

The generated type is indeed valid TypeScript in the sense that it does compile. But if you try to create an object with it, it will still fail:

export type AdditionalProperties = {
  foo?: string;
} & {
  [k: string]: number;
};

const bar: AdditionalProperties = {
    foo: 'baz'
}

/*
Type '{ foo: string; }' is not assignable to type 'AdditionalProperties'.
  Type '{ foo: string; }' is not assignable to type '{ [k: string]: number; }'.
    Property 'foo' is incompatible with index signature.
      Type 'string' is not assignable to type 'number'.
*/

TypeScript Playground

@fabrykowski
Copy link
Author

It is impossible to create such an object in TypeScript, but you can use it after a cast or assertion.

export type AdditionalProperties = {
  foo?: string;
} & {
  [k: string]: number | undefined;
};

const bar = {
    foo: 'bar',
    baz: 42
} as unknown as AdditionalProperties;

const foo = bar.foo; // type: string | undefined
const baz = bar.baz; // type: number | undefined
const tmp = bar.tmp; // type: number | undefined

TypeScript Playground

@fabrykowski
Copy link
Author

This is especially of interest, when a received JSON object – such as an HTTP request body – is parsed.

@fabrykowski
Copy link
Author

We need this in our code base – any chance of a merge?

@Michael-Hering
Copy link

Michael-Hering commented Jul 21, 2021

+1 - TS serverless files are a huge win for teams building serverless apps in TS and this PR would allow that to happen

@maximseshuk
Copy link

+1

2 similar comments
@luc-heyday
Copy link

+1

@electblake
Copy link

+1

@fabrykowski
Copy link
Author

fabrykowski commented Oct 26, 2021

Any news? We could really use this in our production code.
@bcherny This PR could close three issues at once.

# Conflicts:
#	test/__snapshots__/test/test.ts.md
#	test/__snapshots__/test/test.ts.snap
@macsj200
Copy link

macsj200 commented Nov 9, 2021

I would greatly appreciate this fix as I'm currently blocked by serverless/typescript#27.

@fabrykowski
Copy link
Author

@bcherny – this PR would close multiple tickets. Would it be possible to initiate a review?

@G-Rath
Copy link
Contributor

G-Rath commented May 12, 2022

@fabrykowski (and potential reviewers) please keep in mind my comment here: #356 (comment)

This change will be breaking as it prevents you from using declaration merging (which can only be done with interfaces) which can be a very powerful tool with additionalProperties.

A better solution would be to generate a KnownProperties interface that is used in-place of the anonymous interface in the union that this change has generated.

e.g.

interface AdditionalPropertiesKnownProperties {
  foo?: string;
}

export type AdditionalProperties = AdditionalPropertiesKnownProperties & { 
  [k: string]: number;
};

@fabrykowski
Copy link
Author

@G-Rath – in the example you gave only the finished type AdditionalProperties is exported – which means that declaration merging would not be available. On the other hand exporting the [...]KnownProperties-interface for each type with additionalProperties seems a tad unwieldy and pollutes – in my opinion – the namespace.

I would argue that declaration merging is a very useful tool for DefinitelyTyped, allowing for example to define specific headers; should not the aim of this package be the opposite? If I have a JSON-schema and I validate a JSON-object (e.g. via ajv) then I can safely cast the object to (or assert it to be) the generated TypeScript-type! (This is, at least, the way I use this package.)

If there is strong demand for it, I could add another optional compile option (additionalPropertiesInterfaceSuffix?) which, when not falsey, turnes on the export of interface {TypeName}{additionalPropertiesInterfaceSuffix}. I believe, however, that this should be a second PR, because this PR is "breaking" only in the most formal sense, since the currently generated interface does not compile anyways. (This PR still returns an interface, when no additionalProperties are present.)

@G-Rath
Copy link
Contributor

G-Rath commented Jul 31, 2022

in the example you gave only the finished type AdditionalProperties is exported ...
exporting the [...]KnownProperties-interface for each type with additionalProperties seems a tad unwieldy and pollutes – in my opinion – the namespace.

Sure in a perfect world it'd be great to less stuff while still being able to have the flexibility but this is what the language requires us to do in order for this change to not be breaking + retain that flexibility.

should not the aim of this package be the opposite
I can safely cast the object to (or assert it to be) the generated TypeScript-type!

While neither are terrible alternatives, they are not without costs: casting can introduce bugs if not understood and maintained properly because the whole point of them is to have TypeScript ignore type violations like missing properties and type mismatches, and assertions can have a significant runtime cost depending on the size of the objects (which people may shortcut with approximation assertions, which is fine but then you're in the first tradeoff again).

because this PR is "breaking" only in the most formal sense
This PR still returns an interface, when no additionalProperties are present.

Why is this being re-written as a type then?

I'm definitely happier if it doesn't change interfaces with no additionalProperties are present, but I still think because there's a solution that allows you to have your cake and eat it too is better than this current one - splitting that out into another config option IMO is bloating the library needlessly.


Please keep in mind that not everyone is validating their objects against the actual schemas at runtime - for example, over at https://github.com/octokit/webhooks we maintain schemas for all the webhook payloads which are used to generate the types that consumed by related packages; while the schemas are valid and can be used, they are not by the related octokit packages that are using the types. This is also the case for the rest of the GitHub API.

Declaration merging is very powerful here for the same reason its powerful over at DT: if we've made a mistake, or there's some new feature, and no one's gotten around to updating the schemas & types (we're pretty responsive but stuff happens), then people can easily amend the types using DM.

@yourbuddyconner
Copy link

yourbuddyconner commented Dec 12, 2022

Bumping this PR because it appears that TS declaration files are straight up broken (still). I applied this fix to my local node_modules, but like... come on.

How about less pedantic discussion about types and more fixy fixy?

EDIT: I now realize this isn't a serverless-namespaced package. I retract my snark, but getting this merged would still be helpful to me!

@barbunzel
Copy link

Would also love to have this merged!

Copy link
Owner

@bcherny bcherny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay.

This should be implemented as a normalizer rule, and not as a special case in the parser. The rule should be: If a schema specifies non-boolean additionalProperties, then rewrite the schema as an allOf of the schema (without additionalProperties) and the additionalProperties. That would simplify the implementation, and address @G-Rath's point about backwards-compatibility for people taking advantage of declaration merging.

* This interface was referenced by \`Callback\`'s JSON-Schema definition␊
* via the \`patternProperty\` "^x-".␊
*/␊
[k: string]: unknown;␊
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we improve this output?

@roman-shandurenko
Copy link

Can we get it merged, please? The issue has been open for 2 years and is still relevant and causes problems.

@adambiggs
Copy link

Pretty please?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet