-
Notifications
You must be signed in to change notification settings - Fork 12.8k
Relax visibility rules for type-aliases when 'declaration' compiler option is set. #14286
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
Comments
Here is the problem, an ambient module has all its members automatically exported. That was a design decision we made early on to avoid clutter in declaration files (turned out not to be so wise after all:)). So for instance: declare module "Mod" {
var x: number;
export function f(): void;
} In the example above, both Now to the declaration emitter. the emitter is trying to emit
So we picked the last option, as it seemed the safest, and most responsible. Now for relaxing the rule for type aliases, not sure why a type alias is different from an interface, different from a class that your users will only use in a type position.. A change in any of these will break the compilation of your users regardless if they took a dependency on it. |
I see. But a lot of this stems off of just how "real" type-aliases are. Since I started working with TypeScript, I've been seeing them being treated as non-corporeal; they vanish as soon as you look at them. This has led me to believe that the community consensus is that this is how a type-alias should be treated by default. They disappear in the TypeScript playground, they disappear in the IDEs, they're even known to disappear in compiler error messages. Interfaces, on the other hand, keep their name like it is holy throughout the entire code-base. The TypeScript Handbook even makes special note of this in its "Interfaces vs Type Aliases" section:
This If I didn't make an alias public by exporting its name into my module's API, then it's either not important enough to keep its name or I made a mistake when authoring my API (and the only negative consequence would be that my API's type-definitions are a little messier than I intend, but it would still function fine, otherwise). I know that perhaps the idea was to help people catch that kind of mistake, but I argue that this violates pre-established rules regarding type-aliases, and it's more important for the longevity and continued maintenance of the language to keep those rules until we have a really good reason to break them. And if you really look at it, the trade-off on compromising this rule is actually not all that great. I'm basically being forced to either:
This is just not a good trade-off for the safety and responsibility you've suggested we're gaining with this. With this restraint on type-aliases, there is significant pressure to make something messy for someone somewhere. |
If the type alias is not exported, how would the user declare a variable of the specific type? For me it seems that if the library author needed to make their code clean chances are the user would find it beneficial as well. I treat type aliases as part of the API surface, inlining them just pushes the problem to the consumer. |
At least for me, my response is, "why would I want them to declare such a variable?" I almost exclusively use type-aliases privately or with the intention they're kept internal to my package. Most of the problems I have here are due to there not being an I can also foresee circumstances where I really do just want to de-alias away a type-alias' name even though its exposed publicly. Aliases for callbacks are probably a prime example; the function signature actually says a lot more about how the callback will be used than any name I'd give it. I still don't want to be forced to type out the whole signature every time, however. And then, there's still the exceptional case of the odd type-alias I actually do want to make available for public consumption or I expect to have such common use throughout my code it deserves a proper name. For those, I would explicitly export them. Types like This is basically what my coding practices for type-aliases have been up until I flipped on the I would just like to have greater control over what my public API looks like. I haven't heard any really compelling arguments not to give that control back to the developer. Any possible negative consequences mentioned so far could easily be picked out by a linter and ignored if they're actually intentional. |
I am in agreement with JHawkley. Aliases are not types, even according to the documentation, and should not be treated as types. Aliases are often used internally, for code readability. If a user of your module also requires an alias, they can make their own. However, aliases are often used in instances where it is not necessary for the user of your module to deal directly with the type definition. For example, when providing a type literal (object, callback function) or one of a union of types. Since Aliases are not real types, they should be handled in the parser, not the compiler. A simple way to deal with the aliasing would be to keep a map of known aliases when parsing the code, and when it comes across a name, perform a lookup, replacing the alias with the thing it is aliasing. I do not see any complexities in this, it would in fact be no different than if no alias was used in the first place, and we know that that works fine. When aliasing a class or interface, you would still need to make sure the class or interface was public if needed, or you would get an error on the class or interface, not the alias, since it shouldn't really exist in the first place. |
I can't agree more. As @mhegazy mentioned, there are three options (which typescript chose the last for now) for solving that an unexported type being referenced by an exported one. And what we're hoping here is to choose the second one when it's possible. I did an experiment and find out that type aliases inside a function body would be serialized in the emitted declaration file. Take the code snippet in the OP as an example: // source.ts
type CharCallback = (c: string, i: number) => string;
export function mapOnChar(str: string, fn: CharCallback): string {
return '' // removed implementation detail for simplicity
}
export const mapOnChar2 = (() => {
type CharCallback = (c: string, i: number) => string
return function mapOnChar(str: string, fn: CharCallback): string {
return '' // removed implementation detail for simplicity
})()
// emited source.d.ts
declare type CharCallback = (c: string, i: number) => string;
export declare function mapOnChar(str: string, fn: CharCallback): string;
export declare const mapOnChar2: (str: string, fn: (c: string, i: number) => string) => string; Regarding @mhegazy 's concern:
As users of this API, if we were to use the original version and were not pretty sure about what signature it is expecting for the callback, here is how the IDE could, at best, help us: While, the intellisence for the inlined version of the API would be: The difference is obvious. One might argue that users can check the // source.ts
type SomeRecursive<T extends string> = {
[k in T]: SomeRecursive<Exclude<T, k>>
}
function factory<T extends string>(...t: T[]): SomeRecursive<T> {
return {} as any // removed implementation detail for simplicity
}
export const foo = factory('a', 'b', 'c')
// source.d.ts
declare type SomeRecursive<T extends string> = {
[k in T]: SomeRecursive<Exclude<T, k>>;
};
export declare const foo: SomeRecursive<"a" | "b" | "c">; const factory = (() => {
type SomeRecursive<T extends string> = {
[k in T]: SomeRecursive<Exclude<T, k>>
}
return function<T extends string>(...t: T[]): SomeRecursive<T> {
return {} as any // removed implementation detail for simplicity
}
})()
export const bar = factory('a', 'b', 'c')
// source.d.ts
export declare const bar: {
a: {
b: {
c: {};
};
c: {
b: {};
};
};
b: {
a: {
c: {};
};
c: {
a: {};
};
};
c: {
a: {
b: {};
};
b: {
a: {};
};
};
}; I am aware that under certain circumstances (I ran into one myself earlier), serializing the type could be expensive or even impossible. But we don't really need the inlining everywhere and those occurrences where we expect inlinings are most likely simple ones. So wherever the compiler fails to perform an inlinging, it can fall back to option 3 and seek for a human intervention. |
One common thing I use type-aliases for is to give shorter, more expressive names to commonly used types throughout a file.
This is especially useful for callback signatures that see common use throughout a module, as well as string-literal types used in a union type, like
type HttpMethods = 'get' | 'post' | 'put' | 'delete'
.Let's look at the following simplified example:
When using the
declaration
compiler option, this example fails to compile with the following error:error TS4078: Parameter 'fn' of exported function has or is using private name 'CharCallback'.
This initially makes sense; the type-alias is not being exported so it is private. However, if you look at the type-alias, the only thing "private" about it is the name it was given, and an alias' name is not really important to or needed for a definition file.
There is no reason that the un-exported type-alias in the example cannot be automatically de-aliased back into
(c: string, i: number) => string
and that used in its place when the definition file is emitted.However, if the alias in the example were to be exported, then it should not be de-aliased in the definition file.
I should point out that this suggestion is considering type-aliases only. If the
CharCallback
type was re-written asinterface CharCallback { (c: string, i: number): string }
, then that would be a good case to raise an error. Aninterface
is generally considered more "concrete" than a simple type-alias, and so its name should be preserved and used in the definition file.The text was updated successfully, but these errors were encountered: