Skip to content

Instantly share code, notes, and snippets.

@dragomirtitian
Last active November 27, 2023 22:39
Show Gist options
  • Save dragomirtitian/7a838d5c1a35f78c6edc950982f30cb4 to your computer and use it in GitHub Desktop.
Save dragomirtitian/7a838d5c1a35f78c6edc950982f30cb4 to your computer and use it in GitHub Desktop.
`isolatedDeclarations` findings

A few findings from experimenting with isolated declarations:

New required type annotations

Exported variables

Exported variables will need an explicit type annotation (✅ implemented).

export const c1: 1 = 1; // ✔ ok, is explicit
export const c2 = 1 + c1 // ❌, no  explicit annotation

export let l1: number = 1; // ✔ ok, is explicit
export let l2 = 1 + c1 // ❌, no explicit annotation

export var v1: number = 1; // ✔ ok, is explicit
export var v2 = 1 + c1 // ❌, no explicit annotation

Literal initialized variables

⚠ not implemented

One possible exception to this are const declarations that have number, string, or boolean literals assigned to them. These currently use export declare const x = 1; in the declarations, and this behavior could be preserved even in isolated declarations

export const c1 = 1; // could be allowed ❓
export let l1 = 1; // ❌ let declarations should have annotations, even if they are assign literals
export var l1 = 1; // ❌ var declarations should have annotations, even if they are assign literals

Function initialized variables

⚠ not implemented

It is a common pattern to have a variable that holds a function. If the function type is fully specified (as detailed lower), an external tool could in principle move the type annotation from the function type to the variable.

//src.ts
export let fn = (param: string): string => param;
//src.d.ts
export declare let fn: (param: string) => string;

Class initialized variables

⚠ not implemented

If a variable is initialized with a class expression, an external tool could in principle, if the type of the class is fully specified as detailed below, move the type information from the class expression to the variable.

//src.ts
export let cls = class {
    method(): void {}
}
//src.d.ts
export declare let cls: {
    new (): {
        method(): void;
    };
};

Exported functions

Return Type

All exported functions will require explicit type annotations on the return type (✅ implemented)

Possible exceptions:

  1. Functions that do not have a return statement or have return without value, could be trivially inferred to void without type information. (⚠ not implemented)
  2. Functions that only return literal values, could also be trivially inferred to the base type of the literal (or union of them).(⚠ not implemented)

While in both of these possible exception cases we could infer the return type just syntactically based on what is in the file, it might be confusing to require the annotation in all but these very narrow cases. As a first iteration it might be best to always require the annotation

❕ A suggestion already exists that adds the inferred return type.

Expando functions

Currently typescript allows for properties to be added to functions. So this is valid TypeScript code:

// src.ts
export function test(): void {

}
test.prop = 10;

// src.d.ts
export declare function test(): void;
export declare namespace test {
    var prop: number;
}

Since this will use the type of the assigned expression to type the property, and since there is no place to put an annotation in an assignment expression, this kind of expanding function type will be forbidden under isolatedDeclaration (✅ implemented). Users will need to go back to specifying explicit namespaces for the extra function properties.

export function test(): void {

}
declare namespace test {
    var prop: number
}
test.prop = 10;

❓ We could consider allowing the addition of properties where the an assertion is used in the assignment as a sort of type annotation, although the semantics of that are obviously different (test.prop = 10 as number)

Exported Classes

Fields

Private fields (both ES private and TS private ) will not require annotations as these don't get preserved in declarations anyway.

Public and protected fields will require explicit annotations (✅ implemented).

Similarly as for const declarations, readonly declarations that have literals assigned to them could be exempt from the annotation requirement as they use assignment syntax in the declaration currently.(⚠ not implemented)

// index.ts
export class Base {
    readonly x = 10;
}
// index.d.ts
export declare class Base {
    readonly x = 10;
}

Exemptions for field initialized with functions or classes could be considered as for variable.(⚠ not implemented)

Methods

Private fields (both ES private and TS private ) will not require any extra annotations.

Public and protected methods will require explicit annotations on the return type (✅ implemented).

Accessor type

Currently is is sufficient to specify the type on only one of the get or the set accessors, with TypeScript inferring the type for the other. As long as there is an annotation on at least one of then, any tool that wants to independently emit declarations should be able to copy it from there, so this behavior is compatible with isolatedDeclarations (✅ implemented)

export class A1 {
    // ❌ No annotation should error under isolatedDeclarations
    get x() { return "" }
}
export class A2 {
    // ❌ No annotation should error under isolatedDeclarations
    set x(value) {  }
}
export class A3 {
    // ❌ No annotation should error under isolatedDeclarations
    get x() { return "" }
    set x(value) {  }
}
export class A4 {
    // ✔ Annotation can be retrieved from other accessor
    get x() { return "" }
    set x(value: string) { }
}

export class A5 {
    get x(): string { return "" }
    // ✔ Annotation can be retrieved from other accessor
    set x(value) { }
}

Expression in extends clause

When declaration emit encounters and expression in the extends clause of a type, it will create an extra variable in the declaration file, type it as the type of the extends expression and use that as the base type in the class declaration.

// src.ts
class Base { baseMethod() { }}
function id<T extends new (...a: any[]) => any>(cls: T) {
    return class extends cls { 
        mixin(): void;
    }
}
export class Derived extends id(Base) {

}

export class DerivedInline extends class Based { baseMethod(){} } {

}

//src.d.ts
declare class Base {
    baseMethod(): void;
}
declare const Derived_base: {
    new (...a: any[]):
        mixin(): void;
    };
} & typeof Base;
export declare class Derived extends Derived_base {
}
declare const DerivedInline_base: {
    new (): {
        baseMethod(): void;
    };
};
export declare class DerivedInline extends DerivedInline_base {
}

This relies heavily on the type checker to get the type of the extends expression. As such it can't be supported under isolatedDeclaration (✅ implemented). Also since there is no good place to put an explicit type annotation for the extends clause, the only workaround is to do the same as declaration emit currently does and separate out the extends expression into a variable and put a type annotation on the variable.

class Base { baseMethod() { }}
function id<T extends new (...a: any[]) => {  } >(cls: T) {
    return class extends cls {  mixin() { } }
}
const Derived_base: {
    new (...a: any[]): {
        mixin(): void;
    };
} & typeof Base = id(Base) 

export class Derived extends Derived_base {

}
const DerivedInline_base:{
    new (): {
        baseMethod(): void;
    };
} = class Base { baseMethod(){} } 
export class DerivedInline extends DerivedInline_base {

}

❓ A suggestion could be provided to do this automatically.

Const Enums

Const enums are currently forbidden under isolatedModules, under isolatedDeclarations we can allow them, but there might be a differences in the way emit works for them. Currently if a const enum is used in the definition of another const enum, the original value is resolved. This would not be possible for isolatedDeclarations as the declaration of the enum might come from another file. We could instead not resolve the const value and keep the original assignment.

// src.ts
export const enum BaseEnum {
    V = 1,
}
export const enum DerivedEnum {
    V = BaseEnum.V,
}
// src.d.ts - currently
export const enum BaseEnum {
    V = 1,
}
export const enum DerivedEnum {
    V = 1,
}
// src.d.ts - proposed
export const enum BaseEnum {
    V = 1,
}
export const enum DerivedEnum {
    V = BaseEnum.V,
}

An external tool couldn't check that BaseEnum.V is indeed a constant, so it could not emit the same error as TS, but the assumption is that tsc will be used for type errors.

Emit resolver methods that are still used

The POC is still under development, the current resolver methods are still used. I am still currently evaluating if it is reasonable for an external tool to implement these resolver methods or if their usage is problematic.

isLateBound

Not sure what this does in declaration, still need to look at it.

if (isDeclaration(input)) {
    if (isDeclarationAndNotVisible(input)) return;
    if (hasDynamicName(input) && !resolver.isLateBound(getParseTreeNode(input) as Declaration)) {
        return;
    }
}

isDeclarationVisible

While this function comes from the resolver, it seems to be doing only basic syntactic work, so this seems ok to use. An external tool would need to re-implement this, although such an implementation seems straight forward.

rewriteModuleSpecifier

Some module specifiers get rewritten during declaration emit, and these in turn use getSymbolOfExternalModuleSpecifier and getExternalModuleNameFromDeclaration from the resolver which both do a fair amount of work. This does not seem to be related to types necessarily, but it does seem to encode a lot of logic that an external tool would need to closely track.

❓ Is keeping module specifiers 'as is' an option?

getTypeReferenceDirectivesForEntityName and getTypeReferenceDirectivesForSymbol

Both these functions seems to be used to keep track of referenced imports.

❓ Maybe an external tool can just keep all imports in the declaration even if they are not used in the types ?

isEntityNameVisible and isSymbolAccessible

These functions are used only to issue errors, and their return values don't make it into the declaration output, so their usage is fine. An external tool would not give the same errors, but the assumption is that it tsc would be used for getting errors anyway.

isOptionalParameter

The functionality of this function would need to be duplicated by any external tools.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment