From 05ea71ae75f29a838c1501d025072b27989f694f Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Thu, 24 Sep 2026 08:22:39 +0100 Subject: [PATCH 01/12] feat(dts): type `ngAcceptInputType_*` with the transform's parameter type Library `.d.ts` declarations wrote `static ngAcceptInputType_: unknown` for every input with a transform, so consumers' template type-checking accepted any value. ngtsc writes the type of the transform's first parameter, so e.g. `[count]="true"` against a `(value: string | number) => number` transform is a type error. That type is now printed the way ngtsc prints it: `@angular/core` names become `i0.Name`, string literals are re-quoted, spacing is normalised, local and global names are kept, `unknown` for a transform with no parameters. Two deliberate differences from ngtsc, both falling back to the previous `unknown`: - a type referencing another module: ngtsc adds `import * as iN` for it, but aliases numbered per source file can't be merged safely into bundled declaration files - an imported transform, whose signature can't be read from one file A member `@Input` overriding an `inputs:` entry decides the type, like the compiled inputs map. Also emits `"ng-component"` as the `.d.ts` selector of a component without one, as ngtsc does (it was `never`). --- .../src/component/transform.rs | 16 +- .../src/directive/dts_type.rs | 279 +++++++++++ .../src/directive/evaluator.rs | 82 +++ .../oxc_angular_compiler/src/directive/mod.rs | 2 + crates/oxc_angular_compiler/src/dts.rs | 39 +- .../tests/decorator_metadata_ngtsc_test.rs | 7 +- .../fixtures/decorator_metadata_ngtsc.json | 469 ++++++++++++++++++ 7 files changed, 877 insertions(+), 17 deletions(-) create mode 100644 crates/oxc_angular_compiler/src/directive/dts_type.rs diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 2f6184ae0..a707fc36c 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2881,12 +2881,18 @@ pub fn transform_angular_file( .type_parameters .as_ref() .map_or(0, |tp| tp.params.len() as u32); + let accept_types = crate::directive::input_transform_types( + class, + &string_consts, + source, + ); result.dts_declarations.push(dts::generate_component_dts( &metadata, type_argument_count, &content_query_names, has_injectable, &compilation_result.ng_content_selectors, + &accept_types, )); result.component_count += 1; @@ -3003,9 +3009,13 @@ pub fn transform_angular_file( let type_argument_count = class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); directive_metadata.type_argument_count = type_argument_count; - result - .dts_declarations - .push(dts::generate_directive_dts(&directive_metadata, has_injectable)); + let accept_types = + crate::directive::input_transform_types(class, &string_consts, source); + result.dts_declarations.push(dts::generate_directive_dts( + &directive_metadata, + has_injectable, + &accept_types, + )); // Emit setClassMetadata for TestBed support (overrideDirective + // signal members), mirroring the @Component path. diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs new file mode 100644 index 000000000..7a98f71a4 --- /dev/null +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -0,0 +1,279 @@ +//! Prints a TypeScript type the way ngtsc writes it into a `.d.ts`: through +//! its `TypeEmitter` (`@angular/core` names become `i0.Name`, string literals +//! are re-quoted) and TypeScript's printer (normalised spacing). +//! +//! Used for `static ngAcceptInputType_x: T;`, where `T` is the type of the +//! input transform's first parameter. + +use oxc_ast::ast::{ + TSLiteral, TSSignature, TSTupleElement, TSType, TSTypeName, TSTypeOperatorOperator, + TSTypeQueryExprName, +}; +use oxc_span::GetSpan; + +use super::evaluator::FileScope; +use crate::output::emitter::format_number_like_js; + +pub(crate) struct TypePrinter<'s, 'a> { + pub scope: &'s FileScope<'a>, + pub source: &'a str, + /// Set when the type references a module other than `@angular/core`. + /// ngtsc would add an `import * as iN` for it; oxc emits `unknown` instead, + /// since aliases numbered per source file can't be merged into bundled + /// declaration files safely. + pub other_module: bool, +} + +impl TypePrinter<'_, '_> { + pub(crate) fn print(&mut self, ty: &TSType<'_>) -> String { + match ty { + TSType::TSAnyKeyword(_) => "any".into(), + TSType::TSBigIntKeyword(_) => "bigint".into(), + TSType::TSBooleanKeyword(_) => "boolean".into(), + TSType::TSIntrinsicKeyword(_) => "intrinsic".into(), + TSType::TSNeverKeyword(_) => "never".into(), + TSType::TSNullKeyword(_) => "null".into(), + TSType::TSNumberKeyword(_) => "number".into(), + TSType::TSObjectKeyword(_) => "object".into(), + TSType::TSStringKeyword(_) => "string".into(), + TSType::TSSymbolKeyword(_) => "symbol".into(), + TSType::TSUndefinedKeyword(_) => "undefined".into(), + TSType::TSUnknownKeyword(_) => "unknown".into(), + TSType::TSVoidKeyword(_) => "void".into(), + TSType::TSThisType(_) => "this".into(), + TSType::TSTypeReference(r) => { + let mut out = self.type_name(&r.type_name); + if let Some(args) = &r.type_arguments { + out.push_str(&self.type_args(&args.params)); + } + out + } + TSType::TSUnionType(u) => self.join(&u.types, " | "), + TSType::TSIntersectionType(i) => self.join(&i.types, " & "), + TSType::TSParenthesizedType(p) => format!("({})", self.print(&p.type_annotation)), + TSType::TSArrayType(a) => format!("{}[]", self.print(&a.element_type)), + TSType::TSIndexedAccessType(i) => { + format!("{}[{}]", self.print(&i.object_type), self.print(&i.index_type)) + } + TSType::TSTypeOperatorType(o) => { + let op = match o.operator { + TSTypeOperatorOperator::Keyof => "keyof", + TSTypeOperatorOperator::Unique => "unique", + TSTypeOperatorOperator::Readonly => "readonly", + }; + format!("{op} {}", self.print(&o.type_annotation)) + } + TSType::TSTupleType(t) => { + let items: Vec = + t.element_types.iter().map(|e| self.tuple_element(e)).collect(); + format!("[{}]", items.join(", ")) + } + TSType::TSNamedTupleMember(m) => self.named_tuple_member(m), + TSType::TSLiteralType(l) => self.literal(&l.literal), + TSType::TSTemplateLiteralType(t) => { + let mut out = String::from("`"); + for (i, quasi) in t.quasis.iter().enumerate() { + out.push_str(quasi.value.raw.as_str()); + if let Some(ty) = t.types.get(i) { + out.push_str("${"); + out.push_str(&self.print(ty)); + out.push('}'); + } + } + out.push('`'); + out + } + TSType::TSTypeQuery(q) => { + // `typeof x` names a value; ngtsc leaves it as written. + let mut out = match &q.expr_name { + TSTypeQueryExprName::IdentifierReference(id) => format!("typeof {}", id.name), + TSTypeQueryExprName::QualifiedName(q) => { + format!("typeof {}", self.slice(q.span())) + } + other => format!("typeof {}", self.slice(other.span())), + }; + if let Some(args) = &q.type_arguments { + out.push_str(&self.type_args(&args.params)); + } + out + } + TSType::TSTypeLiteral(l) if l.members.is_empty() => "{}".into(), + TSType::TSTypeLiteral(l) => { + let members: Vec = l.members.iter().map(|m| self.signature(m)).collect(); + format!("{{ {} }}", members.join(" ")) + } + TSType::TSFunctionType(f) => { + let type_params = + f.type_parameters.as_ref().map_or(String::new(), |t| self.slice(t.span)); + format!( + "{type_params}({}) => {}", + self.params(&f.params, f.this_param.as_ref().map(|t| t.span)), + self.print(&f.return_type.type_annotation) + ) + } + TSType::TSConstructorType(c) => { + let type_params = + c.type_parameters.as_ref().map_or(String::new(), |t| self.slice(t.span)); + format!( + "{}new {type_params}({}) => {}", + if c.r#abstract { "abstract " } else { "" }, + self.params(&c.params, None), + self.print(&c.return_type.type_annotation) + ) + } + TSType::TSConditionalType(c) => format!( + "{} extends {} ? {} : {}", + self.print(&c.check_type), + self.print(&c.extends_type), + self.print(&c.true_type), + self.print(&c.false_type) + ), + // Mapped, infer, predicate, import and JSDoc types are printed as written. + other => self.slice(other.span()), + } + } + + fn join(&mut self, types: &[TSType<'_>], separator: &str) -> String { + types.iter().map(|t| self.print(t)).collect::>().join(separator) + } + + fn type_args(&mut self, params: &[TSType<'_>]) -> String { + format!("<{}>", self.join(params, ", ")) + } + + /// `@angular/core` names become `i0.Name`; local and global names stay as + /// written; names from other modules set `other_module`. + fn type_name(&mut self, name: &TSTypeName<'_>) -> String { + let (head, rest) = match name { + TSTypeName::IdentifierReference(id) => (id.name.as_str(), String::new()), + TSTypeName::QualifiedName(q) => { + let mut left = &q.left; + let mut rest = format!(".{}", q.right.name); + while let TSTypeName::QualifiedName(inner) = left { + rest = format!(".{}{rest}", inner.right.name); + left = &inner.left; + } + match left { + TSTypeName::IdentifierReference(id) => (id.name.as_str(), rest), + other => return self.slice(other.span()) + &rest, + } + } + TSTypeName::ThisExpression(_) => return "this".into(), + }; + match self.scope.import(head) { + Some(import) if import.module == "@angular/core" => match import.imported { + Some(imported) => format!("i0.{imported}{rest}"), + None => format!("i0{rest}"), + }, + Some(_) => { + self.other_module = true; + format!("{head}{rest}") + } + None => format!("{head}{rest}"), + } + } + + fn tuple_element(&mut self, element: &TSTupleElement<'_>) -> String { + match element { + TSTupleElement::TSOptionalType(o) => format!("{}?", self.print(&o.type_annotation)), + TSTupleElement::TSRestType(r) => format!("...{}", self.print(&r.type_annotation)), + other => self.print(other.to_ts_type()), + } + } + + fn named_tuple_member(&mut self, m: &oxc_ast::ast::TSNamedTupleMember<'_>) -> String { + format!( + "{}{}: {}", + m.label.name, + if m.optional { "?" } else { "" }, + self.tuple_element(&m.element_type) + ) + } + + fn literal(&self, literal: &TSLiteral<'_>) -> String { + match literal { + TSLiteral::BooleanLiteral(b) => b.value.to_string(), + TSLiteral::NumericLiteral(n) => format_number_like_js(n.value), + TSLiteral::BigIntLiteral(b) => self.slice(b.span), + TSLiteral::StringLiteral(s) => format!("\"{}\"", escape_string(&s.value)), + TSLiteral::TemplateLiteral(t) => self.slice(t.span), + TSLiteral::UnaryExpression(u) => self.slice(u.span).split_whitespace().collect(), + } + } + + fn signature(&mut self, member: &TSSignature<'_>) -> String { + match member { + TSSignature::TSPropertySignature(p) => { + let key = if p.computed { + format!("[{}]", self.slice(p.key.span())) + } else { + self.slice(p.key.span()) + }; + let ty = p + .type_annotation + .as_ref() + .map_or(String::new(), |t| format!(": {}", self.print(&t.type_annotation))); + format!( + "{}{key}{}{ty};", + if p.readonly { "readonly " } else { "" }, + if p.optional { "?" } else { "" }, + ) + } + other => { + let text = self.slice(other.span()); + let text = text.trim_end_matches([';', ',']).to_string(); + format!("{};", text.split_whitespace().collect::>().join(" ")) + } + } + } + + fn params( + &mut self, + params: &oxc_ast::ast::FormalParameters<'_>, + this: Option, + ) -> String { + let mut out: Vec = this.map(|s| self.slice(s)).into_iter().collect(); + for param in ¶ms.items { + let mut text = self.slice(param.pattern.span()); + if param.optional { + text.push('?'); + } + if let Some(t) = ¶m.type_annotation { + text.push_str(": "); + text.push_str(&self.print(&t.type_annotation)); + } + out.push(text); + } + if let Some(rest) = ¶ms.rest { + let mut text = format!("...{}", self.slice(rest.rest.argument.span())); + if let Some(t) = &rest.type_annotation { + text.push_str(": "); + text.push_str(&self.print(&t.type_annotation)); + } + out.push(text); + } + out.join(", ") + } + + fn slice(&self, span: oxc_span::Span) -> String { + span.source_text(self.source).to_string() + } +} + +fn escape_string(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04X}", c as u32)); + } + c => out.push(c), + } + } + out +} diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index a4cae41a0..d8cf34a5d 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3065,3 +3065,85 @@ fn loose_equals(a: &Value<'_>, b: &Value<'_>) -> bool { _ => to_number(a) == to_number(b), } } + +// ============================================================================= +// `.d.ts` input transform types +// ============================================================================= + +/// The `.d.ts` type of each input transform's first parameter, for +/// `static ngAcceptInputType_: T;`. +/// +/// Only transforms defined in this file are covered; imported ones can't be +/// inspected and are left out. +pub fn input_transform_types<'a>( + class: &'a Class<'a>, + consts: &super::StringConsts<'a>, + source: &'a str, +) -> HashMap { + let evaluator = Evaluator::new(consts); + // The transform that ends up compiled for each input: a member `@Input` + // overrides an `inputs:` entry, like the compiled inputs map. + let mut transforms: std::vec::Vec<(String, Option>)> = std::vec::Vec::new(); + let mut record = |name: String, value: &Value<'a>| { + let def = match value { + Value::Function(def) => Some(*def), + Value::Reference { kind: RefKind::Function(function, _), .. } => { + Some(FnDef::Function(function)) + } + _ => None, + }; + match transforms.iter_mut().find(|(n, _)| *n == name) { + Some(entry) => entry.1 = def, + None => transforms.push((name, def)), + } + }; + if let Some((Some(config), _)) = super::angular_decorator_config(class) + && let Some(inputs) = super::decorator::config_property(config, "inputs", consts) + && let Value::Array(items) = evaluator.evaluate(inputs) + { + for item in &items { + if let (Some(Value::String(name)), Some(transform)) = + (item.prop("name").map(|p| &p.value), item.prop("transform")) + { + record(name.clone(), &transform.value); + } + } + } + for element in &class.body.body { + let (key, decorators) = match element { + ClassElement::PropertyDefinition(p) => (&p.key, &p.decorators), + ClassElement::AccessorProperty(p) => (&p.key, &p.decorators), + ClassElement::MethodDefinition(m) => (&m.key, &m.decorators), + _ => continue, + }; + let Some(name) = key.static_name() else { continue }; + if let Some(options) = + super::property_decorators::input_decorator_options(decorators, consts) + && let Some(transform) = evaluator.evaluate(options).prop("transform") + { + record(name.to_string(), &transform.value); + } + } + + transforms + .into_iter() + .filter_map(|(name, def)| { + let ty = match def?.first_param_type().ok()? { + Some(ty) => { + let mut printer = super::dts_type::TypePrinter { + scope: consts.scope(), + source, + other_module: false, + }; + let ty = printer.print(ty); + if printer.other_module { + return None; + } + ty + } + None => "unknown".to_string(), + }; + Some((name, ty)) + }) + .collect() +} diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index f058580e3..7872b7130 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -15,6 +15,7 @@ mod compiler; mod decorator; mod definition; +mod dts_type; mod evaluator; mod metadata; mod property_decorators; @@ -37,6 +38,7 @@ pub(crate) use decorator::{ merge_by_class_property, parse_decorator_io, resolve_member_transforms, }; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; +pub use evaluator::input_transform_types; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index c062e73fd..8effc70ce 100644 --- a/crates/oxc_angular_compiler/src/dts.rs +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -20,6 +20,7 @@ use crate::ng_module::NgModuleMetadata; use crate::pipe::PipeMetadata; use crate::service::ServiceMetadata; use oxc_str::Ident; +use std::collections::HashMap; /// A `.d.ts` type declaration for an Angular class. /// @@ -55,6 +56,7 @@ pub fn generate_component_dts( content_query_names: &[String], has_injectable: bool, ng_content_selectors: &[String], + accept_types: &HashMap, ) -> DtsDeclaration { let class_name = metadata.class_name.as_str(); let type_with_params = type_with_parameters(class_name, type_argument_count); @@ -73,7 +75,7 @@ pub fn generate_component_dts( let cleaned = s.as_str().replace('\n', ""); format!("\"{}\"", escape_dts_string(&cleaned)) } - None => "never".to_string(), + None => "\"ng-component\"".to_string(), }; let export_as = if metadata.export_as.is_empty() { @@ -155,7 +157,7 @@ pub fn generate_component_dts( } // Add ngAcceptInputType_* fields for non-signal inputs with transform functions - generate_input_transform_fields(&metadata.inputs, &mut members); + generate_input_transform_fields(&metadata.inputs, accept_types, &mut members); DtsDeclaration { class_name: class_name.to_string(), members } } @@ -172,6 +174,7 @@ pub fn generate_component_dts( pub fn generate_directive_dts( metadata: &R3DirectiveMetadata, has_injectable: bool, + accept_types: &HashMap, ) -> DtsDeclaration { let class_name = metadata.name.as_str(); let type_with_params = type_with_parameters(class_name, metadata.type_argument_count); @@ -259,7 +262,7 @@ pub fn generate_directive_dts( } // Add ngAcceptInputType_* fields for non-signal inputs with transform functions - generate_input_transform_fields(&metadata.inputs, &mut members); + generate_input_transform_fields(&metadata.inputs, accept_types, &mut members); DtsDeclaration { class_name: class_name.to_string(), members } } @@ -551,27 +554,39 @@ fn generate_ctor_deps_type_from_factory_deps( /// Generate `ngAcceptInputType_*` static fields for non-signal inputs with transform functions. /// /// When an input has a `transform` function (e.g., `@Input({transform: booleanAttribute})`), -/// Angular generates a static field like: +/// Angular generates a static field typed with the transform's first parameter: /// ```text -/// static ngAcceptInputType_disabled: unknown; +/// static ngAcceptInputType_disabled: boolean | string; /// ``` /// This enables template type-checking to know that transformed inputs accept wider types. /// /// Signal inputs do NOT generate these fields (they capture WriteT within the InputSignal type). /// -/// Note: We use `unknown` as the type because we don't have access to the TypeScript type checker -/// to determine the actual write type of the transform function. -fn generate_input_transform_fields(inputs: &[R3InputMetadata], members: &mut String) { +/// `accept_types` maps input names to that type for transforms defined in the +/// same file; the rest (imported transforms) fall back to `unknown`, since their +/// signature isn't visible here. +fn generate_input_transform_fields( + inputs: &[R3InputMetadata], + accept_types: &HashMap, + members: &mut String, +) { for input in inputs { if !input.is_signal && input.transform_function.is_some() { - members.push_str(&format!( - "\nstatic ngAcceptInputType_{}: unknown;", - input.class_property_name.as_str() - )); + let name = input.class_property_name.as_str(); + let field = format!("ngAcceptInputType_{name}"); + let field = if is_identifier_name(&field) { field } else { format!("\"{field}\"") }; + let ty = accept_types.get(name).map_or("unknown", String::as_str); + members.push_str(&format!("\nstatic {field}: {ty};")); } } } +fn is_identifier_name(name: &str) -> bool { + let mut chars = name.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_alphanumeric() || c == '_' || c == '$') +} + /// Generate the input map type for `ɵɵComponentDeclaration` / `ɵɵDirectiveDeclaration`. /// /// Produces a TypeScript object literal type like: diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 41ab4d0c2..db4af9aa4 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 754, "fixtures compared"); + assert_eq!(compared, 775, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -801,8 +801,11 @@ export function coerce(v: string) { return 1; } class Utils { static coerce(v: boolean) { return 2; } } @Directive({selector: '[d]'}) export class Dir { @Input({transform: Utils.coerce}) x: any; }"; - let code = strip(&transform(source).code); + let result = transform(source); + let code = strip(&result.code); assert!(code.contains(r#"inputs:{x:[2,"x","x",Utils.coerce]}"#), "{code}"); + let dts = &result.dts_declarations[0].members; + assert!(dts.contains("static ngAcceptInputType_x: boolean;"), "{dts}"); } /// A transform read through a namespace import (`core.booleanAttribute`): diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index acfbb4dee..f708ca5de 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -286,6 +286,58 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "ngtsc_spec: should *not* generate a validator fn for attribute and property bindings when *not* on