Copilot commented on code in PR #3394: URL: https://github.com/apache/fory/pull/3394#discussion_r2853315097
########## compiler/fory_compiler/tests/test_typescript_codegen.py: ########## @@ -0,0 +1,374 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for TypeScript code generation.""" + +from pathlib import Path +from textwrap import dedent + +from fory_compiler.frontend.fdl.lexer import Lexer +from fory_compiler.frontend.fdl.parser import Parser +from fory_compiler.generators.base import GeneratorOptions +from fory_compiler.generators.typescript import TypeScriptGenerator +from fory_compiler.ir.ast import Schema + + +def parse_fdl(source: str) -> Schema: + return Parser(Lexer(source).tokenize()).parse() + + +def generate_typescript(source: str) -> str: + schema = parse_fdl(source) + options = GeneratorOptions(output_dir=Path("/tmp")) + generator = TypeScriptGenerator(schema, options) + files = generator.generate() + assert len(files) == 1, f"Expected 1 file, got {len(files)}" + return files[0].content + + +def test_typescript_enum_generation(): + """Test that enums are properly generated.""" + source = dedent( + """ + package example; + + enum Color [id=101] { + RED = 0; + GREEN = 1; + BLUE = 2; + } + """ + ) + output = generate_typescript(source) + + # Check enum definition + assert "export enum Color" in output + assert "RED = 0" in output + assert "GREEN = 1" in output + assert "BLUE = 2" in output + assert "Type ID 101" in output + + +def test_typescript_message_generation(): + """Test that messages are properly generated as interfaces.""" + source = dedent( + """ + package example; + + message Person [id=102] { + string name = 1; + int32 age = 2; + optional string email = 3; + } + """ + ) + output = generate_typescript(source) + + # Check interface definition + assert "export interface Person" in output + assert "name: string;" in output + assert "age: number;" in output + assert "email: string | undefined;" in output + assert "Type ID 102" in output + + +def test_typescript_nested_message(): + """Test that nested messages are properly generated.""" + source = dedent( + """ + package example; + + message Person [id=100] { + string name = 1; + + message Address [id=101] { + string street = 1; + string city = 2; + } + + Address address = 2; + } + """ + ) + output = generate_typescript(source) + + # Check nested interface + assert "export interface Person" in output + assert "export interface Address" in output + assert "street: string;" in output + assert "city: string;" in output + + +def test_typescript_nested_enum(): + """Test that nested enums are properly generated.""" + source = dedent( + """ + package example; + + message Person [id=100] { + string name = 1; + + enum PhoneType [id=101] { + MOBILE = 0; + HOME = 1; + } + } + """ + ) + output = generate_typescript(source) + + # Check nested enum + assert "export enum PhoneType" in output + assert "MOBILE = 0" in output + assert "HOME = 1" in output + + +def test_typescript_nested_enum_registration_uses_simple_name(): + """Test that nested enums are registered with simple names, not qualified names.""" + source = dedent( + """ + package example; + + message Person [id=100] { + string name = 1; + + enum PhoneType [id=101] { + MOBILE = 0; + HOME = 1; + } + } + """ + ) + output = generate_typescript(source) + + # Check that nested enum is registered with simple name (not qualified name) + assert "fory.register(PhoneType, 101)" in output + # Ensure qualified names are NOT used + assert "Person.PhoneType" not in output + + +def test_typescript_union_generation(): + """Test that unions are properly generated as discriminated unions.""" + source = dedent( + """ + package example; + + message Dog [id=101] { + string name = 1; + int32 bark_volume = 2; + } + + message Cat [id=102] { + string name = 1; + int32 lives = 2; + } + + union Animal [id=103] { + Dog dog = 1; + Cat cat = 2; + } + """ + ) + output = generate_typescript(source) + + # Check union generation + assert "export enum AnimalCase" in output + assert "DOG = 1" in output + assert "CAT = 2" in output + assert "export type Animal" in output + assert "AnimalCase.DOG" in output + assert "AnimalCase.CAT" in output + assert "Type ID 103" in output + + +def test_typescript_collection_types(): + """Test that collection types are properly mapped.""" + source = dedent( + """ + package example; + + message Data [id=100] { + repeated string items = 1; + map<string, int32> config = 2; + } + """ + ) + output = generate_typescript(source) + + # Check collection types + assert "items: string[];" in output + assert "config: Record<string, number>;" in output + + +def test_typescript_primitive_types(): + """Test that all primitive types are properly mapped.""" + source = dedent( + """ + package example; + + message AllTypes [id=100] { + bool f_bool = 1; + int32 f_int32 = 2; + int64 f_int64 = 3; + uint32 f_uint32 = 4; + uint64 f_uint64 = 5; + float f_float = 6; + double f_double = 7; + string f_string = 8; + bytes f_bytes = 9; + } + """ + ) + output = generate_typescript(source) + + # Check type mappings (field names are converted to camelCase) + assert "fBool: boolean;" in output + assert "fInt32: number;" in output + assert "fInt64: bigint | number;" in output + assert "fUint32: number;" in output + assert "fUint64: bigint | number;" in output + assert "fFloat: number;" in output + assert "fDouble: number;" in output + assert "fString: string;" in output + assert "fBytes: Uint8Array;" in output + + +def test_typescript_file_structure(): + """Test that generated file has proper structure.""" + source = dedent( + """ + package example.v1; + + enum Status [id=100] { + UNKNOWN = 0; + ACTIVE = 1; + } + + message Request [id=101] { + string query = 1; + } + + union Response [id=102] { + string result = 1; + string error = 2; + } + """ + ) + output = generate_typescript(source) + + # Check license header + assert "Apache Software Foundation (ASF)" in output + assert "Licensed" in output + + # Check package comment + assert "Package: example.v1" in output + + # Check section comments + assert "// Enums" in output + assert "// Messages" in output + assert "// Unions" in output + assert "// Registration helper" in output + + # Check registration function (uses last segment of package name) + assert "export function registerV1Types" in output + + +def test_typescript_field_naming(): + """Test that field names are converted to camelCase.""" + source = dedent( + """ + package example; + + message Person [id=100] { + string first_name = 1; + string last_name = 2; + int32 phone_number = 3; + } + """ + ) + output = generate_typescript(source) + + # Check that field names are properly converted to camelCase + assert "firstName:" in output + assert "lastName:" in output + assert "phoneNumber:" in output + # Ensure snake_case is not used + assert "first_name:" not in output + assert "last_name:" not in output + assert "phone_number:" not in output + + +def test_typescript_no_runtime_dependencies(): + """Test that generated code has no gRPC runtime dependencies.""" + source = dedent( + """ + package example; + + message Request [id=100] { + string query = 1; + } + """ + ) + output = generate_typescript(source) + + # Should not reference gRPC + assert "@grpc" not in output + assert "grpc-js" not in output + assert "require('grpc" not in output + assert "import.*grpc" not in output + Review Comment: This test intends to ensure no gRPC imports, but `assert "import.*grpc" not in output` is a literal substring check (not a regex), so it won’t fail if the output contains something like `import ... grpc`. Use `re.search(r"import.*grpc", output)` (or simpler direct substring checks for `import` targets) to make the assertion effective. ########## compiler/fory_compiler/generators/typescript.py: ########## @@ -0,0 +1,377 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""TypeScript/JavaScript code generator. + +Generates pure TypeScript type definitions from FDL IDL files. +Supports messages, enums, unions, and all primitive types. +""" + +from pathlib import Path +from typing import List, Optional, Tuple + +from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.ir.ast import ( + Message, + Enum, + Union, + FieldType, + PrimitiveType, + NamedType, + ListType, + MapType, +) +from fory_compiler.ir.types import PrimitiveKind + + +class TypeScriptGenerator(BaseGenerator): + """Generates TypeScript type definitions and interfaces from IDL.""" + + language_name = "typescript" + file_extension = ".ts" + + # Mapping from FDL primitive types to TypeScript types + PRIMITIVE_MAP = { + PrimitiveKind.BOOL: "boolean", + PrimitiveKind.INT8: "number", + PrimitiveKind.INT16: "number", + PrimitiveKind.INT32: "number", + PrimitiveKind.VARINT32: "number", + PrimitiveKind.INT64: "bigint | number", + PrimitiveKind.VARINT64: "bigint | number", + PrimitiveKind.TAGGED_INT64: "bigint | number", + PrimitiveKind.UINT8: "number", + PrimitiveKind.UINT16: "number", + PrimitiveKind.UINT32: "number", + PrimitiveKind.VAR_UINT32: "number", + PrimitiveKind.UINT64: "bigint | number", + PrimitiveKind.VAR_UINT64: "bigint | number", + PrimitiveKind.TAGGED_UINT64: "bigint | number", + PrimitiveKind.FLOAT16: "number", + PrimitiveKind.BFLOAT16: "number", + PrimitiveKind.FLOAT32: "number", + PrimitiveKind.FLOAT64: "number", + PrimitiveKind.STRING: "string", + PrimitiveKind.BYTES: "Uint8Array", + PrimitiveKind.DATE: "Date", + PrimitiveKind.TIMESTAMP: "Date", + PrimitiveKind.DURATION: "number", + PrimitiveKind.DECIMAL: "number", + PrimitiveKind.ANY: "any", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.indent_str = " " # TypeScript uses 2 spaces + + def is_imported_type(self, type_def: object) -> bool: + """Return True if a type definition comes from an imported IDL file.""" + schema_file = self.schema.source_file + + # If there's no source file set, all types are local (not imported) + if not schema_file: + return False + + location = getattr(type_def, "location", None) + if location is None or not location.file: + return False + + # If the type's location matches the schema's source file, it's local + if schema_file == location.file: + return False + + # Otherwise, try to resolve paths and compare + try: + return Path(location.file).resolve() != Path(schema_file).resolve() + except Exception: + # If Path resolution fails, compare as strings + return location.file != schema_file + + def split_imported_types( + self, items: List[object] + ) -> Tuple[List[object], List[object]]: + imported: List[object] = [] + local: List[object] = [] + for item in items: + if self.is_imported_type(item): + imported.append(item) + else: + local.append(item) + return imported, local # Return (imported, local) tuple + + def get_module_name(self) -> str: + """Get the TypeScript module name from package.""" + if self.package: + # Convert package name to camelCase file name + parts = self.package.split(".") + return self.to_camel_case(parts[-1]) + return "generated" + + def generate_type(self, field_type: FieldType, nullable: bool = False) -> str: + """Generate TypeScript type string for a field type.""" + type_str = "" + + if isinstance(field_type, PrimitiveType): + type_str = self.PRIMITIVE_MAP.get(field_type.kind, "any") + elif isinstance(field_type, NamedType): + # Check if this NamedType matches a primitive type name + primitive_name = field_type.name.lower() + # Map common shorthand names to primitive kinds + shorthand_map = { + "float": PrimitiveKind.FLOAT32, + "double": PrimitiveKind.FLOAT64, + } + if primitive_name in shorthand_map: + type_str = self.PRIMITIVE_MAP.get(shorthand_map[primitive_name], "any") + else: + # Check if it matches any primitive kind directly + for primitive_kind, ts_type in self.PRIMITIVE_MAP.items(): + if primitive_kind.value == primitive_name: + type_str = ts_type + break + if not type_str: + # If not a primitive, treat as a message/enum type + type_str = self.to_pascal_case(field_type.name) + elif isinstance(field_type, ListType): + element_type = self.generate_type(field_type.element_type) Review Comment: For `ListType`, `element_optional` isn’t reflected in the generated TypeScript type. Currently `list<optional T>` / `repeated optional T` will still emit `T[]` instead of something like `(T | undefined)[]`, which is a type mismatch. ```suggestion # Respect optionality of list elements, if available on the IR node. element_nullable = getattr(field_type, "element_optional", False) element_type = self.generate_type(field_type.element_type, nullable=element_nullable) # If the element type is a union (e.g., "T | undefined"), wrap it so # the array applies to the whole union: (T | undefined)[] if " | " in element_type: element_type = f"({element_type})" ``` ########## compiler/fory_compiler/generators/typescript.py: ########## @@ -0,0 +1,377 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""TypeScript/JavaScript code generator. + +Generates pure TypeScript type definitions from FDL IDL files. +Supports messages, enums, unions, and all primitive types. +""" + +from pathlib import Path +from typing import List, Optional, Tuple + +from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.ir.ast import ( + Message, + Enum, + Union, + FieldType, + PrimitiveType, + NamedType, + ListType, + MapType, +) +from fory_compiler.ir.types import PrimitiveKind + + +class TypeScriptGenerator(BaseGenerator): + """Generates TypeScript type definitions and interfaces from IDL.""" + + language_name = "typescript" + file_extension = ".ts" + + # Mapping from FDL primitive types to TypeScript types + PRIMITIVE_MAP = { + PrimitiveKind.BOOL: "boolean", + PrimitiveKind.INT8: "number", + PrimitiveKind.INT16: "number", + PrimitiveKind.INT32: "number", + PrimitiveKind.VARINT32: "number", + PrimitiveKind.INT64: "bigint | number", + PrimitiveKind.VARINT64: "bigint | number", + PrimitiveKind.TAGGED_INT64: "bigint | number", + PrimitiveKind.UINT8: "number", + PrimitiveKind.UINT16: "number", + PrimitiveKind.UINT32: "number", + PrimitiveKind.VAR_UINT32: "number", + PrimitiveKind.UINT64: "bigint | number", + PrimitiveKind.VAR_UINT64: "bigint | number", + PrimitiveKind.TAGGED_UINT64: "bigint | number", + PrimitiveKind.FLOAT16: "number", + PrimitiveKind.BFLOAT16: "number", + PrimitiveKind.FLOAT32: "number", + PrimitiveKind.FLOAT64: "number", + PrimitiveKind.STRING: "string", + PrimitiveKind.BYTES: "Uint8Array", + PrimitiveKind.DATE: "Date", + PrimitiveKind.TIMESTAMP: "Date", + PrimitiveKind.DURATION: "number", + PrimitiveKind.DECIMAL: "number", + PrimitiveKind.ANY: "any", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.indent_str = " " # TypeScript uses 2 spaces + + def is_imported_type(self, type_def: object) -> bool: + """Return True if a type definition comes from an imported IDL file.""" + schema_file = self.schema.source_file + + # If there's no source file set, all types are local (not imported) + if not schema_file: + return False + + location = getattr(type_def, "location", None) + if location is None or not location.file: + return False + + # If the type's location matches the schema's source file, it's local + if schema_file == location.file: + return False + + # Otherwise, try to resolve paths and compare + try: + return Path(location.file).resolve() != Path(schema_file).resolve() + except Exception: + # If Path resolution fails, compare as strings + return location.file != schema_file + + def split_imported_types( + self, items: List[object] + ) -> Tuple[List[object], List[object]]: + imported: List[object] = [] + local: List[object] = [] + for item in items: + if self.is_imported_type(item): + imported.append(item) + else: + local.append(item) + return imported, local # Return (imported, local) tuple + + def get_module_name(self) -> str: + """Get the TypeScript module name from package.""" + if self.package: + # Convert package name to camelCase file name + parts = self.package.split(".") + return self.to_camel_case(parts[-1]) + return "generated" + + def generate_type(self, field_type: FieldType, nullable: bool = False) -> str: + """Generate TypeScript type string for a field type.""" + type_str = "" + + if isinstance(field_type, PrimitiveType): + type_str = self.PRIMITIVE_MAP.get(field_type.kind, "any") + elif isinstance(field_type, NamedType): + # Check if this NamedType matches a primitive type name + primitive_name = field_type.name.lower() + # Map common shorthand names to primitive kinds + shorthand_map = { + "float": PrimitiveKind.FLOAT32, + "double": PrimitiveKind.FLOAT64, + } + if primitive_name in shorthand_map: + type_str = self.PRIMITIVE_MAP.get(shorthand_map[primitive_name], "any") + else: + # Check if it matches any primitive kind directly + for primitive_kind, ts_type in self.PRIMITIVE_MAP.items(): + if primitive_kind.value == primitive_name: + type_str = ts_type + break + if not type_str: + # If not a primitive, treat as a message/enum type + type_str = self.to_pascal_case(field_type.name) + elif isinstance(field_type, ListType): + element_type = self.generate_type(field_type.element_type) + type_str = f"{element_type}[]" + elif isinstance(field_type, MapType): + key_type = self.generate_type(field_type.key_type) + value_type = self.generate_type(field_type.value_type) + type_str = f"Record<{key_type}, {value_type}>" Review Comment: `MapType` is emitted as `Record<keyType, valueType>`, but `Record<K, V>` requires `K extends string | number | symbol`. Some valid FDL schemas (e.g., `map<int64, ...>`) will map to `Record<bigint | number, ...>` which won’t type-check. Consider normalizing map keys to `string` (or emitting `Map<K, V>` / constraining supported key kinds). ```suggestion # Use Record only for key types allowed by TypeScript's Record<K, V> if key_type in ("string", "number", "symbol"): type_str = f"Record<{key_type}, {value_type}>" else: # Fallback to Map<K, V> for other key types (e.g., bigint) type_str = f"Map<{key_type}, {value_type}>" ``` ########## compiler/fory_compiler/generators/typescript.py: ########## @@ -0,0 +1,377 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""TypeScript/JavaScript code generator. + +Generates pure TypeScript type definitions from FDL IDL files. +Supports messages, enums, unions, and all primitive types. +""" + +from pathlib import Path +from typing import List, Optional, Tuple + +from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.ir.ast import ( + Message, + Enum, + Union, + FieldType, + PrimitiveType, + NamedType, + ListType, + MapType, +) +from fory_compiler.ir.types import PrimitiveKind + + +class TypeScriptGenerator(BaseGenerator): + """Generates TypeScript type definitions and interfaces from IDL.""" + + language_name = "typescript" + file_extension = ".ts" + + # Mapping from FDL primitive types to TypeScript types + PRIMITIVE_MAP = { + PrimitiveKind.BOOL: "boolean", + PrimitiveKind.INT8: "number", + PrimitiveKind.INT16: "number", + PrimitiveKind.INT32: "number", + PrimitiveKind.VARINT32: "number", + PrimitiveKind.INT64: "bigint | number", + PrimitiveKind.VARINT64: "bigint | number", + PrimitiveKind.TAGGED_INT64: "bigint | number", + PrimitiveKind.UINT8: "number", + PrimitiveKind.UINT16: "number", + PrimitiveKind.UINT32: "number", + PrimitiveKind.VAR_UINT32: "number", + PrimitiveKind.UINT64: "bigint | number", + PrimitiveKind.VAR_UINT64: "bigint | number", + PrimitiveKind.TAGGED_UINT64: "bigint | number", + PrimitiveKind.FLOAT16: "number", + PrimitiveKind.BFLOAT16: "number", + PrimitiveKind.FLOAT32: "number", + PrimitiveKind.FLOAT64: "number", + PrimitiveKind.STRING: "string", + PrimitiveKind.BYTES: "Uint8Array", + PrimitiveKind.DATE: "Date", + PrimitiveKind.TIMESTAMP: "Date", + PrimitiveKind.DURATION: "number", + PrimitiveKind.DECIMAL: "number", + PrimitiveKind.ANY: "any", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.indent_str = " " # TypeScript uses 2 spaces + + def is_imported_type(self, type_def: object) -> bool: + """Return True if a type definition comes from an imported IDL file.""" + schema_file = self.schema.source_file + + # If there's no source file set, all types are local (not imported) + if not schema_file: + return False + + location = getattr(type_def, "location", None) + if location is None or not location.file: + return False + + # If the type's location matches the schema's source file, it's local + if schema_file == location.file: + return False + + # Otherwise, try to resolve paths and compare + try: + return Path(location.file).resolve() != Path(schema_file).resolve() + except Exception: + # If Path resolution fails, compare as strings + return location.file != schema_file + + def split_imported_types( + self, items: List[object] + ) -> Tuple[List[object], List[object]]: + imported: List[object] = [] + local: List[object] = [] + for item in items: + if self.is_imported_type(item): + imported.append(item) + else: + local.append(item) + return imported, local # Return (imported, local) tuple + + def get_module_name(self) -> str: + """Get the TypeScript module name from package.""" + if self.package: + # Convert package name to camelCase file name + parts = self.package.split(".") + return self.to_camel_case(parts[-1]) + return "generated" + + def generate_type(self, field_type: FieldType, nullable: bool = False) -> str: + """Generate TypeScript type string for a field type.""" + type_str = "" + + if isinstance(field_type, PrimitiveType): + type_str = self.PRIMITIVE_MAP.get(field_type.kind, "any") + elif isinstance(field_type, NamedType): + # Check if this NamedType matches a primitive type name + primitive_name = field_type.name.lower() + # Map common shorthand names to primitive kinds + shorthand_map = { + "float": PrimitiveKind.FLOAT32, + "double": PrimitiveKind.FLOAT64, + } + if primitive_name in shorthand_map: + type_str = self.PRIMITIVE_MAP.get(shorthand_map[primitive_name], "any") + else: + # Check if it matches any primitive kind directly + for primitive_kind, ts_type in self.PRIMITIVE_MAP.items(): + if primitive_kind.value == primitive_name: + type_str = ts_type + break + if not type_str: + # If not a primitive, treat as a message/enum type + type_str = self.to_pascal_case(field_type.name) + elif isinstance(field_type, ListType): Review Comment: `generate_type()` doesn’t handle qualified type references like `Parent.Child`. The FDL parser allows dotted names, but `to_pascal_case()` will leave the dot in place, producing an invalid TypeScript identifier/reference. Consider resolving nested/qualified names (e.g., by flattening or mapping `Parent.Child` to the generated symbol name) consistently with how nested types are emitted. ########## compiler/fory_compiler/generators/typescript.py: ########## @@ -0,0 +1,377 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""TypeScript/JavaScript code generator. + +Generates pure TypeScript type definitions from FDL IDL files. +Supports messages, enums, unions, and all primitive types. +""" + +from pathlib import Path +from typing import List, Optional, Tuple + +from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.ir.ast import ( + Message, + Enum, + Union, + FieldType, + PrimitiveType, + NamedType, + ListType, + MapType, +) +from fory_compiler.ir.types import PrimitiveKind + + +class TypeScriptGenerator(BaseGenerator): + """Generates TypeScript type definitions and interfaces from IDL.""" + + language_name = "typescript" + file_extension = ".ts" + + # Mapping from FDL primitive types to TypeScript types + PRIMITIVE_MAP = { + PrimitiveKind.BOOL: "boolean", + PrimitiveKind.INT8: "number", + PrimitiveKind.INT16: "number", + PrimitiveKind.INT32: "number", + PrimitiveKind.VARINT32: "number", + PrimitiveKind.INT64: "bigint | number", + PrimitiveKind.VARINT64: "bigint | number", + PrimitiveKind.TAGGED_INT64: "bigint | number", + PrimitiveKind.UINT8: "number", + PrimitiveKind.UINT16: "number", + PrimitiveKind.UINT32: "number", + PrimitiveKind.VAR_UINT32: "number", + PrimitiveKind.UINT64: "bigint | number", + PrimitiveKind.VAR_UINT64: "bigint | number", + PrimitiveKind.TAGGED_UINT64: "bigint | number", + PrimitiveKind.FLOAT16: "number", + PrimitiveKind.BFLOAT16: "number", + PrimitiveKind.FLOAT32: "number", + PrimitiveKind.FLOAT64: "number", + PrimitiveKind.STRING: "string", + PrimitiveKind.BYTES: "Uint8Array", + PrimitiveKind.DATE: "Date", + PrimitiveKind.TIMESTAMP: "Date", + PrimitiveKind.DURATION: "number", + PrimitiveKind.DECIMAL: "number", + PrimitiveKind.ANY: "any", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.indent_str = " " # TypeScript uses 2 spaces + + def is_imported_type(self, type_def: object) -> bool: + """Return True if a type definition comes from an imported IDL file.""" + schema_file = self.schema.source_file + + # If there's no source file set, all types are local (not imported) + if not schema_file: + return False + + location = getattr(type_def, "location", None) + if location is None or not location.file: + return False + + # If the type's location matches the schema's source file, it's local + if schema_file == location.file: + return False + + # Otherwise, try to resolve paths and compare + try: + return Path(location.file).resolve() != Path(schema_file).resolve() + except Exception: + # If Path resolution fails, compare as strings + return location.file != schema_file + + def split_imported_types( + self, items: List[object] + ) -> Tuple[List[object], List[object]]: + imported: List[object] = [] + local: List[object] = [] + for item in items: + if self.is_imported_type(item): + imported.append(item) + else: + local.append(item) + return imported, local # Return (imported, local) tuple + + def get_module_name(self) -> str: + """Get the TypeScript module name from package.""" + if self.package: + # Convert package name to camelCase file name + parts = self.package.split(".") + return self.to_camel_case(parts[-1]) + return "generated" + + def generate_type(self, field_type: FieldType, nullable: bool = False) -> str: + """Generate TypeScript type string for a field type.""" + type_str = "" + + if isinstance(field_type, PrimitiveType): + type_str = self.PRIMITIVE_MAP.get(field_type.kind, "any") + elif isinstance(field_type, NamedType): + # Check if this NamedType matches a primitive type name + primitive_name = field_type.name.lower() + # Map common shorthand names to primitive kinds + shorthand_map = { + "float": PrimitiveKind.FLOAT32, + "double": PrimitiveKind.FLOAT64, + } + if primitive_name in shorthand_map: + type_str = self.PRIMITIVE_MAP.get(shorthand_map[primitive_name], "any") + else: + # Check if it matches any primitive kind directly + for primitive_kind, ts_type in self.PRIMITIVE_MAP.items(): + if primitive_kind.value == primitive_name: + type_str = ts_type + break + if not type_str: + # If not a primitive, treat as a message/enum type + type_str = self.to_pascal_case(field_type.name) + elif isinstance(field_type, ListType): + element_type = self.generate_type(field_type.element_type) + type_str = f"{element_type}[]" + elif isinstance(field_type, MapType): + key_type = self.generate_type(field_type.key_type) + value_type = self.generate_type(field_type.value_type) + type_str = f"Record<{key_type}, {value_type}>" + else: + type_str = "any" + + if nullable: + type_str += " | undefined" + + return type_str + + def generate(self) -> List[GeneratedFile]: + """Generate TypeScript files for the schema.""" + files = [] + files.append(self.generate_module()) + return files + + def generate_module(self) -> GeneratedFile: + """Generate a TypeScript module with all types.""" + lines = [] + + # License header + lines.append(self.get_license_header("//")) + lines.append("") + + # Add package comment if present + if self.package: + lines.append(f"// Package: {self.package}") + lines.append("") + + # Generate enums (top-level only) + _, local_enums = self.split_imported_types(self.schema.enums) + if local_enums: + lines.append("// Enums") + lines.append("") + for enum in local_enums: + lines.extend(self.generate_enum(enum)) + lines.append("") + + # Generate unions (top-level only) + _, local_unions = self.split_imported_types(self.schema.unions) + if local_unions: + lines.append("// Unions") + lines.append("") + for union in local_unions: + lines.extend(self.generate_union(union)) + lines.append("") + + # Generate messages (including nested types) + _, local_messages = self.split_imported_types(self.schema.messages) + if local_messages: + lines.append("// Messages") + lines.append("") + for message in local_messages: + lines.extend(self.generate_message(message, indent=0)) + lines.append("") + + # Generate registration function + lines.extend(self.generate_registration()) + lines.append("") + + return GeneratedFile( + path=f"{self.get_module_name()}{self.file_extension}", + content="\n".join(lines), + ) + + def generate_enum(self, enum: Enum, indent: int = 0) -> List[str]: + """Generate a TypeScript enum.""" + lines = [] + ind = self.indent_str * indent + comment = self.format_type_id_comment(enum, f"{ind}//") + if comment: + lines.append(comment) + + lines.append(f"{ind}export enum {enum.name} {{") + for value in enum.values: + stripped_name = self.strip_enum_prefix(enum.name, value.name) + lines.append(f"{ind}{self.indent_str}{stripped_name} = {value.value},") + lines.append(f"{ind}}}") + + return lines + + def generate_message( + self, + message: Message, + indent: int = 0, + parent_stack: Optional[List[Message]] = None, + ) -> List[str]: + """Generate a TypeScript interface for a message.""" + lines = [] + ind = self.indent_str * indent + lineage = (parent_stack or []) + [message] + + comment = self.format_type_id_comment(message, f"{ind}//") + if comment: + lines.append(comment) + + # Generate the main interface first + lines.append(f"{ind}export interface {message.name} {{") + + # Generate fields + for field in message.fields: + field_type = self.generate_type(field.field_type, nullable=field.optional) + lines.append( + f"{ind}{self.indent_str}{self.to_camel_case(field.name)}: {field_type};" + ) + + lines.append(f"{ind}}}") + + # Generate nested enums after parent interface + for nested_enum in message.nested_enums: + lines.append("") + lines.extend(self.generate_enum(nested_enum, indent=indent)) + + # Generate nested unions after parent interface + for nested_union in message.nested_unions: + lines.append("") + lines.extend(self.generate_union(nested_union, indent=indent)) + + # Generate nested messages after parent interface + for nested_msg in message.nested_messages: + lines.append("") + lines.extend( + self.generate_message(nested_msg, indent=indent, parent_stack=lineage) + ) + + return lines + + def generate_union( + self, + union: Union, + indent: int = 0, + parent_stack: Optional[List[Message]] = None, + ) -> List[str]: + """Generate a TypeScript discriminated union.""" + lines = [] + ind = self.indent_str * indent + union_name = union.name + + comment = self.format_type_id_comment(union, f"{ind}//") + if comment: + lines.append(comment) + + # Generate case enum + case_enum_name = f"{union_name}Case" + lines.append(f"{ind}export enum {case_enum_name} {{") + for field in union.fields: + field_name_upper = self.to_upper_snake_case(field.name) + lines.append(f"{ind}{self.indent_str}{field_name_upper} = {field.number},") + lines.append(f"{ind}}}") + lines.append("") + + # Generate union type as discriminated union + union_cases = [] + for field in union.fields: + field_type_str = self.generate_type(field.field_type) + case_value = self.to_upper_snake_case(field.name) + union_cases.append( + f"{ind}{self.indent_str}| ( {{ case: {case_enum_name}.{case_value}; value: {field_type_str} }} )" + ) + + lines.append(f"{ind}export type {union_name} =") + lines.extend(union_cases) + lines.append(f"{ind}{self.indent_str};") + + return lines + + def generate_registration(self) -> List[str]: + """Generate a registration function.""" + lines = [] + registration_name = ( + f"register{self.to_pascal_case(self.get_module_name())}Types" + ) + + lines.append("// Registration helper") + lines.append(f"export function {registration_name}(fory: any): void {{") + + # Register enums + for enum in self.schema.enums: + if self.is_imported_type(enum): + continue + if self.should_register_by_id(enum): + type_id = enum.type_id + lines.append(f" fory.register({enum.name}, {type_id});") + + # Register messages + for message in self.schema.messages: + if self.is_imported_type(message): + continue + self._generate_message_registration(message, lines) + + # Register unions + for union in self.schema.unions: + if self.is_imported_type(union): + continue + if self.should_register_by_id(union): + type_id = union.type_id + lines.append(f" fory.registerUnion({union.name}, {type_id});") + Review Comment: The generated registration helper uses message/union names (`{message.name}`, `{union.name}`) in value position, but messages are emitted as `export interface` and unions as `export type`, which don’t exist at runtime in TypeScript. This will make the generated `.ts` fail to compile (`only refers to a type, but is being used as a value`). Either emit runtime values to register (e.g., classes/constructors or exported descriptors) or remove/adjust registration generation for type-only declarations. ########## compiler/fory_compiler/generators/typescript.py: ########## @@ -0,0 +1,377 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""TypeScript/JavaScript code generator. + +Generates pure TypeScript type definitions from FDL IDL files. +Supports messages, enums, unions, and all primitive types. +""" + +from pathlib import Path +from typing import List, Optional, Tuple + +from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.ir.ast import ( + Message, + Enum, + Union, + FieldType, + PrimitiveType, + NamedType, + ListType, + MapType, +) +from fory_compiler.ir.types import PrimitiveKind + + +class TypeScriptGenerator(BaseGenerator): + """Generates TypeScript type definitions and interfaces from IDL.""" + + language_name = "typescript" + file_extension = ".ts" + + # Mapping from FDL primitive types to TypeScript types + PRIMITIVE_MAP = { + PrimitiveKind.BOOL: "boolean", + PrimitiveKind.INT8: "number", + PrimitiveKind.INT16: "number", + PrimitiveKind.INT32: "number", + PrimitiveKind.VARINT32: "number", + PrimitiveKind.INT64: "bigint | number", + PrimitiveKind.VARINT64: "bigint | number", + PrimitiveKind.TAGGED_INT64: "bigint | number", + PrimitiveKind.UINT8: "number", + PrimitiveKind.UINT16: "number", + PrimitiveKind.UINT32: "number", + PrimitiveKind.VAR_UINT32: "number", + PrimitiveKind.UINT64: "bigint | number", + PrimitiveKind.VAR_UINT64: "bigint | number", + PrimitiveKind.TAGGED_UINT64: "bigint | number", + PrimitiveKind.FLOAT16: "number", + PrimitiveKind.BFLOAT16: "number", + PrimitiveKind.FLOAT32: "number", + PrimitiveKind.FLOAT64: "number", + PrimitiveKind.STRING: "string", + PrimitiveKind.BYTES: "Uint8Array", + PrimitiveKind.DATE: "Date", + PrimitiveKind.TIMESTAMP: "Date", + PrimitiveKind.DURATION: "number", + PrimitiveKind.DECIMAL: "number", + PrimitiveKind.ANY: "any", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.indent_str = " " # TypeScript uses 2 spaces + + def is_imported_type(self, type_def: object) -> bool: + """Return True if a type definition comes from an imported IDL file.""" + schema_file = self.schema.source_file + + # If there's no source file set, all types are local (not imported) + if not schema_file: + return False + + location = getattr(type_def, "location", None) + if location is None or not location.file: + return False + + # If the type's location matches the schema's source file, it's local + if schema_file == location.file: + return False + + # Otherwise, try to resolve paths and compare + try: + return Path(location.file).resolve() != Path(schema_file).resolve() + except Exception: + # If Path resolution fails, compare as strings + return location.file != schema_file + + def split_imported_types( + self, items: List[object] + ) -> Tuple[List[object], List[object]]: + imported: List[object] = [] + local: List[object] = [] + for item in items: + if self.is_imported_type(item): + imported.append(item) + else: + local.append(item) + return imported, local # Return (imported, local) tuple + + def get_module_name(self) -> str: + """Get the TypeScript module name from package.""" + if self.package: + # Convert package name to camelCase file name + parts = self.package.split(".") + return self.to_camel_case(parts[-1]) + return "generated" + + def generate_type(self, field_type: FieldType, nullable: bool = False) -> str: + """Generate TypeScript type string for a field type.""" + type_str = "" + + if isinstance(field_type, PrimitiveType): + type_str = self.PRIMITIVE_MAP.get(field_type.kind, "any") + elif isinstance(field_type, NamedType): + # Check if this NamedType matches a primitive type name + primitive_name = field_type.name.lower() + # Map common shorthand names to primitive kinds + shorthand_map = { + "float": PrimitiveKind.FLOAT32, + "double": PrimitiveKind.FLOAT64, + } + if primitive_name in shorthand_map: + type_str = self.PRIMITIVE_MAP.get(shorthand_map[primitive_name], "any") + else: + # Check if it matches any primitive kind directly + for primitive_kind, ts_type in self.PRIMITIVE_MAP.items(): + if primitive_kind.value == primitive_name: + type_str = ts_type + break + if not type_str: + # If not a primitive, treat as a message/enum type + type_str = self.to_pascal_case(field_type.name) + elif isinstance(field_type, ListType): + element_type = self.generate_type(field_type.element_type) + type_str = f"{element_type}[]" + elif isinstance(field_type, MapType): + key_type = self.generate_type(field_type.key_type) + value_type = self.generate_type(field_type.value_type) + type_str = f"Record<{key_type}, {value_type}>" + else: + type_str = "any" + + if nullable: + type_str += " | undefined" + + return type_str + + def generate(self) -> List[GeneratedFile]: + """Generate TypeScript files for the schema.""" + files = [] + files.append(self.generate_module()) + return files + + def generate_module(self) -> GeneratedFile: + """Generate a TypeScript module with all types.""" + lines = [] + + # License header + lines.append(self.get_license_header("//")) + lines.append("") + + # Add package comment if present + if self.package: + lines.append(f"// Package: {self.package}") + lines.append("") + + # Generate enums (top-level only) + _, local_enums = self.split_imported_types(self.schema.enums) + if local_enums: + lines.append("// Enums") + lines.append("") + for enum in local_enums: + lines.extend(self.generate_enum(enum)) + lines.append("") + + # Generate unions (top-level only) + _, local_unions = self.split_imported_types(self.schema.unions) + if local_unions: + lines.append("// Unions") + lines.append("") + for union in local_unions: + lines.extend(self.generate_union(union)) + lines.append("") + + # Generate messages (including nested types) + _, local_messages = self.split_imported_types(self.schema.messages) + if local_messages: + lines.append("// Messages") + lines.append("") + for message in local_messages: + lines.extend(self.generate_message(message, indent=0)) + lines.append("") + + # Generate registration function + lines.extend(self.generate_registration()) + lines.append("") + + return GeneratedFile( + path=f"{self.get_module_name()}{self.file_extension}", + content="\n".join(lines), + ) + + def generate_enum(self, enum: Enum, indent: int = 0) -> List[str]: + """Generate a TypeScript enum.""" + lines = [] + ind = self.indent_str * indent + comment = self.format_type_id_comment(enum, f"{ind}//") + if comment: + lines.append(comment) + + lines.append(f"{ind}export enum {enum.name} {{") + for value in enum.values: + stripped_name = self.strip_enum_prefix(enum.name, value.name) + lines.append(f"{ind}{self.indent_str}{stripped_name} = {value.value},") + lines.append(f"{ind}}}") + + return lines + + def generate_message( + self, + message: Message, + indent: int = 0, + parent_stack: Optional[List[Message]] = None, + ) -> List[str]: + """Generate a TypeScript interface for a message.""" + lines = [] + ind = self.indent_str * indent + lineage = (parent_stack or []) + [message] + + comment = self.format_type_id_comment(message, f"{ind}//") + if comment: + lines.append(comment) + + # Generate the main interface first + lines.append(f"{ind}export interface {message.name} {{") + + # Generate fields + for field in message.fields: + field_type = self.generate_type(field.field_type, nullable=field.optional) + lines.append( + f"{ind}{self.indent_str}{self.to_camel_case(field.name)}: {field_type};" + ) + + lines.append(f"{ind}}}") + + # Generate nested enums after parent interface + for nested_enum in message.nested_enums: + lines.append("") + lines.extend(self.generate_enum(nested_enum, indent=indent)) + + # Generate nested unions after parent interface + for nested_union in message.nested_unions: + lines.append("") + lines.extend(self.generate_union(nested_union, indent=indent)) + + # Generate nested messages after parent interface + for nested_msg in message.nested_messages: + lines.append("") + lines.extend( + self.generate_message(nested_msg, indent=indent, parent_stack=lineage) + ) + + return lines + + def generate_union( + self, + union: Union, + indent: int = 0, + parent_stack: Optional[List[Message]] = None, + ) -> List[str]: + """Generate a TypeScript discriminated union.""" + lines = [] + ind = self.indent_str * indent + union_name = union.name + + comment = self.format_type_id_comment(union, f"{ind}//") + if comment: + lines.append(comment) + + # Generate case enum + case_enum_name = f"{union_name}Case" + lines.append(f"{ind}export enum {case_enum_name} {{") + for field in union.fields: + field_name_upper = self.to_upper_snake_case(field.name) + lines.append(f"{ind}{self.indent_str}{field_name_upper} = {field.number},") + lines.append(f"{ind}}}") + lines.append("") + + # Generate union type as discriminated union + union_cases = [] + for field in union.fields: + field_type_str = self.generate_type(field.field_type) + case_value = self.to_upper_snake_case(field.name) + union_cases.append( + f"{ind}{self.indent_str}| ( {{ case: {case_enum_name}.{case_value}; value: {field_type_str} }} )" + ) + + lines.append(f"{ind}export type {union_name} =") + lines.extend(union_cases) + lines.append(f"{ind}{self.indent_str};") + + return lines + + def generate_registration(self) -> List[str]: + """Generate a registration function.""" + lines = [] + registration_name = ( + f"register{self.to_pascal_case(self.get_module_name())}Types" + ) + + lines.append("// Registration helper") + lines.append(f"export function {registration_name}(fory: any): void {{") + + # Register enums + for enum in self.schema.enums: + if self.is_imported_type(enum): + continue + if self.should_register_by_id(enum): + type_id = enum.type_id + lines.append(f" fory.register({enum.name}, {type_id});") + + # Register messages + for message in self.schema.messages: + if self.is_imported_type(message): + continue + self._generate_message_registration(message, lines) + + # Register unions + for union in self.schema.unions: + if self.is_imported_type(union): + continue + if self.should_register_by_id(union): + type_id = union.type_id + lines.append(f" fory.registerUnion({union.name}, {type_id});") Review Comment: `generate_registration()` only registers types when `should_register_by_id()` is true; types without an explicit/generated `type_id` will be silently skipped, unlike other generators which fall back to namespace/type-name registration. If TS registration is intended to support non-ID schemas, add the named-registration path (or make the omission explicit). -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
