import { Context, Controller } from "@hotwired/stimulus"; class Wrapped { // @ts-ignore private _ = undefined; } /** * Strongly type Object values * ```ts * const values = { * address: Object_<{ street: string }> * } * ``` */ export const Object_ = Wrapped; export const ObjectAs = Object_; /** * Strongly type custom targets * ```ts * const targets = { * select: Target * } * ``` */ export const Target = Wrapped; /** * Identifier to camel case (admin--user-status to adminUserStatus) */ type CamelCase = K extends `${infer Head}-${infer Tail}` ? `${Head}${Capitalize>}` : K; type ElementType = C extends Controller ? E : never; type Singular = { [K in keyof T as `${CamelCase}${Suffix}`]: T[K]; }; type Existential = { [K in keyof T as `has${Capitalize>}${Suffix}`]: boolean; }; type Plural = { [K in keyof T as `${CamelCase}${Suffix}s`]: T[K][]; }; type Elemental = { [K in keyof T as `${CamelCase}${Suffix}Element`]: ElementType; } & { [K in keyof T as `${CamelCase}${Suffix}Elements`]: ElementType[]; }; type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; type MagicProperties = (Kind extends "Value" ? Singular : Readonly>) & Readonly> & Readonly : unknown> & Readonly : unknown>; type Constructor = new (...args: any[]) => T; type TypeFromConstructor = C extends StringConstructor ? string : C extends NumberConstructor ? number : C extends BooleanConstructor ? boolean : C extends Constructor ? T extends Wrapped ? O : Object extends T ? unknown : T : never; /** * Map `{ [key:string]: Constructor } to { [key:string]: T }` */ type TransformType = { [K in keyof T]: TypeFromConstructor; }; /** * Transform `{ [key:string]: ValueTypeConstant | ValueTypeObject }` */ type TransformValueDefinition = TransformType<{ [K in keyof T]: T[K] extends { type: infer U } ? U : T[K]; }>; // tweak stimulus value definition map to support typed array and object type ValueDefinitionMap = { [token: string]: ValueTypeDefinition; }; type ValueTypeConstant = | typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String | typeof Object_; type ValueTypeDefault = Array | boolean | number | Object | typeof Object_ | string; type ValueTypeObject = Partial<{ type: ValueTypeConstant; default: ValueTypeDefault; }>; type ValueTypeDefinition = ValueTypeConstant | ValueTypeObject; type TargetDefinitionMap = { [token: string]: typeof Element | typeof Target; }; type OutletDefinitionMap = { [token: string]: Constructor; }; type Statics< Values extends ValueDefinitionMap, Targets extends TargetDefinitionMap, Outlets extends OutletDefinitionMap, > = { values?: Values; targets?: Targets; outlets?: Outlets; }; type StimulusProperties< Values extends ValueDefinitionMap, Targets extends TargetDefinitionMap, Outlets extends OutletDefinitionMap, > = Simplify< MagicProperties, "Value"> & MagicProperties, "Target"> & MagicProperties, "Outlet"> >; /** * Convert typed Object_ to ObjectConstructor before passing values to Stimulus */ function patchValueTypeDefinitionMap(values: ValueDefinitionMap) { const patchObject = (def: ValueTypeDefinition) => { if ("type" in def) { return { type: def.type === Object_ ? Object : def.type, default: def.default, }; } else { return def === Object_ ? Object : def; } }; return Object.entries(values).reduce((result, [key, def]) => { result[key] = patchObject(def); return result; }, {} as ValueDefinitionMap); } /** * Strongly typed Controller! * ```ts * const values = { * name: String, * alias: Array, * address: Object_<{ street: string }> * } * const targets = { form: HTMLFormElement, "select": Target } * const outlets = { "user-status": UserStatusController } * * class MyController extends Typed(Controller, { values, targets, outlets }) { * // Look Ma, no "declare ..." * this.nameValue.split(' ') * this.aliasValue.map(alias => alias.toUpperCase()) * this.addressValue.street * this.formTarget.submit() * this.selectTarget.search = "stimulus"; * this.userStatusOutlets.forEach(status => status.markAsSelected(event)) * } * ``` */ export function Typed< Values extends ValueDefinitionMap, Targets extends TargetDefinitionMap, Outlets extends OutletDefinitionMap, Base extends Constructor, >(Base: Base, statics: Statics) { const { values, targets, outlets } = statics; const derived = class extends Base { static values = patchValueTypeDefinitionMap(values ?? {}); static targets = Object.getOwnPropertyNames(targets ?? {}); static outlets = Object.getOwnPropertyNames(outlets ?? {}); }; return derived as unknown as { new (context: Context): InstanceType & StimulusProperties; }; }