diff --git a/package.json b/package.json index a398188c..e1bbd846 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,12 @@ "problems": "tsc -p tsconfig.json --noEmit", "start": "webpack-dev-server", "storybook": "yarn build && yarn workspace basic-example-app storybook", - "test": "yarn format:check && yarn lint && testem ci && yarn test:babel-plugins && yarn test:prettier", + "test": "yarn format:check && yarn lint && testem ci && yarn test:babel-plugins && yarn test:types && yarn test:prettier", "test:babel-plugins": "mocha -r esm --timeout 5000 packages/@glimmerx/babel-preset/test packages/@glimmerx/eslint-plugin/test/lib/rules", "test:ember": "yarn workspace basic-addon ember try:one", "test:prettier": "mocha -r esm packages/@glimmerx/prettier-plugin-component-templates/test", "test:playground": "yarn workspace glimmerx-playground test", + "test:types": "tsc --noEmit --project type-tests", "test:watch": "testem" }, "browserslist": { @@ -51,6 +52,7 @@ "@typescript-eslint/parser": "^4.18.0", "babel-loader": "^8.0.6", "eslint": "^7.29.0", + "expect-type": "^0.13.0", "fs-extra": "^9.0.0", "lerna": "^3.20.2", "prettier": "^2.3.1", diff --git a/packages/@glimmerx/component/index.ts b/packages/@glimmerx/component/index.ts index d9a53162..5eda9503 100644 --- a/packages/@glimmerx/component/index.ts +++ b/packages/@glimmerx/component/index.ts @@ -1,8 +1,21 @@ -import { default as Component } from '@glimmer/component'; - export { default } from '@glimmer/component'; export { tracked } from '@glimmer/tracking'; -export function hbs(_strings: TemplateStringsArray): Component { +// This type exists to provide a non-user-constructible, non-subclassable +// type representing the conceptual "instance type" of a template-only component. +// The abstract field of type `never` prevents subclassing in userspace of +// the value returned from `hbs`. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export declare abstract class TemplateComponentInstance { + protected abstract __concrete__: never; +} + +// By making `TemplateComponent` abstract and impossible to subclass +// (see above), we prevent users from attempting to instantiate a the +// return value of `hbs` themselves, while leaving a hook for tools +// like Glint to augment the type. +export type TemplateComponent = abstract new () => TemplateComponentInstance; + +export function hbs(_strings: TemplateStringsArray): TemplateComponent { throw new Error('hbs template should have been compiled at build time'); } diff --git a/packages/@glimmerx/helper/index.ts b/packages/@glimmerx/helper/index.ts index dfe09dad..bd835ee8 100644 --- a/packages/@glimmerx/helper/index.ts +++ b/packages/@glimmerx/helper/index.ts @@ -1,2 +1,13 @@ -export { helper, Helper } from './src/helper'; -export { fn } from '@glimmer/helper'; +export { helper, HelperFunction, Helper } from './src/helper'; + +import { fn as glimmerFn } from '@glimmer/helper'; + +declare const Brand: unique symbol; + +// This interface provides an extension point for tools like +// Glint to augment with template-specific type information. +export interface FnHelper { + [Brand]: 'helper:fn'; +} + +export const fn = glimmerFn as FnHelper; diff --git a/packages/@glimmerx/helper/src/helper.ts b/packages/@glimmerx/helper/src/helper.ts index 411a9ce9..f5dd196c 100644 --- a/packages/@glimmerx/helper/src/helper.ts +++ b/packages/@glimmerx/helper/src/helper.ts @@ -5,14 +5,29 @@ interface HelperOptions { services: Record; } -export type Helper = ( +export type HelperFunction = ( positional: T, named: U, options: HelperOptions ) => unknown; +// This type exists to provide a non-user-constructible, non-subclassable +// type representing the conceptual "instance type" of a helper. +// The abstract field of type `never` prevents subclassing in userspace of +// the value returned from `helper()`. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export declare abstract class HelperInstance { + protected abstract __concrete__: never; +} + +// Making `Helper` a bare constructor type allows for type parameters to be +// preserved when `helper()` is passed a generic function. By making it +// `abstract` and impossible to subclass (see above), we prevent users from +// attempting to instantiate a return value from `helper()`. +export type Helper = abstract new () => HelperInstance; + interface BasicHelperBucket { - fn: Helper; + fn: HelperFunction; args: Arguments; ownerProxy: Record; } @@ -24,7 +39,7 @@ class BasicHelperManager implements HelperManager { constructor(private owner: Owner | undefined) {} - createHelper(fn: Helper, args: Arguments) { + createHelper(fn: HelperFunction, args: Arguments) { const { owner } = this; const ownerProxy = new Proxy( @@ -54,8 +69,17 @@ class BasicHelperManager implements HelperManager { const basicHelperManagerFactory = (owner: Owner | undefined) => new BasicHelperManager(owner); -export function helper(helperFunction: Helper) { +export function helper

( + helperFunction: (positional: P, named: N, options: HelperOptions) => R +): Helper<{ Args: { Named: N; Positional: P }; Return: R }> { setHelperManager(basicHelperManagerFactory, helperFunction); - return helperFunction; + // Despite actually returning the given function, from a template's + // perspective its associated helper manager now makes it something + // different. It wouldn't be legal to invoke it according to its + // original type any more, and we need to reflect that. + return helperFunction as unknown as Helper<{ + Args: { Named: N; Positional: P }; + Return: R; + }>; } diff --git a/packages/@glimmerx/modifier/index.ts b/packages/@glimmerx/modifier/index.ts index 692bbf65..ad78e280 100644 --- a/packages/@glimmerx/modifier/index.ts +++ b/packages/@glimmerx/modifier/index.ts @@ -1 +1,13 @@ -export { on, action } from '@glimmer/modifier'; +import { action, on as glimmerOn } from '@glimmer/modifier'; + +declare const Brand: unique symbol; + +// This interface provides an extension point for tools like +// Glint to augment with template-specific type information. +export interface OnModifier { + [Brand]: 'modifier:on'; +} + +const on = glimmerOn as OnModifier; + +export { on, action }; diff --git a/tsconfig.json b/tsconfig.json index c00b4e83..5b43c73d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */ }, "exclude": [ - "./packages/@glimmerx/storybook" + "./packages/@glimmerx/storybook", + "./type-tests" ] } diff --git a/type-tests/component.ts b/type-tests/component.ts new file mode 100644 index 00000000..03a24707 --- /dev/null +++ b/type-tests/component.ts @@ -0,0 +1,22 @@ +import { expectTypeOf } from 'expect-type'; +import { hbs, TemplateComponent } from '@glimmerx/component'; + +declare module '@glimmerx/component' { + // For the purposes of testing, make the instance type dependent on the `S` parameter, + // even if in practice the variance wouldn't be quite right. + export interface TemplateComponentInstance { + value: S; + } +} + +type SignatureOf = T extends TemplateComponent ? S : unknown; + +interface GreetSignature { + Args: { target: string }; +} + +export const InferredGreet: TemplateComponent = hbs`Hello, {{@target}}`; + +export const ExplicitGreet = hbs`Hello, {{@target}}`; + +expectTypeOf>().toEqualTypeOf(); diff --git a/type-tests/helper.ts b/type-tests/helper.ts new file mode 100644 index 00000000..7b0e93d9 --- /dev/null +++ b/type-tests/helper.ts @@ -0,0 +1,24 @@ +import { expectTypeOf } from 'expect-type'; +import { helper, Helper, fn, FnHelper } from '@glimmerx/helper'; + +declare module '@glimmerx/helper/dist/commonjs/src/helper' { + // For the purposes of testing, make the instance type dependent on the `S` parameter, + // even if in practice the variance wouldn't be quite right. + export interface HelperInstance { + signature: S; + } +} + +type SignatureOf = T extends Helper ? S : unknown; + +expectTypeOf(fn).toEqualTypeOf(); + +const myHelper = helper((_: [], { arg }: { arg: string }) => arg); + +expectTypeOf>().toEqualTypeOf<{ + Args: { + Positional: []; + Named: { arg: string }; + }; + Return: string; +}>(); diff --git a/type-tests/modifier.ts b/type-tests/modifier.ts new file mode 100644 index 00000000..8e295b3d --- /dev/null +++ b/type-tests/modifier.ts @@ -0,0 +1,4 @@ +import { expectTypeOf } from 'expect-type'; +import { on, OnModifier } from '@glimmerx/modifier'; + +expectTypeOf(on).toEqualTypeOf(); diff --git a/type-tests/tsconfig.json b/type-tests/tsconfig.json new file mode 100644 index 00000000..86e88e31 --- /dev/null +++ b/type-tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "exclude": [] +} diff --git a/yarn.lock b/yarn.lock index 1778503c..c819af24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12134,6 +12134,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" +expect-type@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-0.13.0.tgz#916646a7a73f3ee77039a634ee9035efe1876eb2" + integrity sha512-CclevazQfrqo8EvbLPmP7osnb1SZXkw47XPPvUUpeMz4HuGzDltE7CaIt3RLyT9UQrwVK/LDn+KVcC0hcgjgDg== + express@^4.10.7, express@^4.16.4, express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"