Skip to content

Commit 2fe2cc4

Browse files
committed
fix(rpc): improve type inference for optional fields parameter
- Remove unused resource and action parameters from build_payload_fields - Handle empty fields array in InferResult type to return {} - Add function overloads for create/update actions with optional fields - Add TypeScript tests validating type inference behavior
1 parent 408b411 commit 2fe2cc4

File tree

4 files changed

+345
-14
lines changed

4 files changed

+345
-14
lines changed

lib/ash_typescript/rpc/codegen.ex

Lines changed: 139 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -580,11 +580,13 @@ defmodule AshTypescript.Rpc.Codegen do
580580
type InferResult<
581581
T extends TypedSchema,
582582
SelectedFields extends UnifiedFieldSelection<T>[],
583-
> = UnionToIntersection<
584-
{
585-
[K in keyof SelectedFields]: InferFieldValue<T, SelectedFields[K]>;
586-
}[number]
587-
>;
583+
> = SelectedFields extends []
584+
? {}
585+
: UnionToIntersection<
586+
{
587+
[K in keyof SelectedFields]: InferFieldValue<T, SelectedFields[K]>;
588+
}[number]
589+
>;
588590
589591
// Pagination conditional types
590592
// Checks if a page configuration object has any pagination parameters
@@ -2110,11 +2112,10 @@ defmodule AshTypescript.Rpc.Codegen do
21102112
config_fields
21112113
end
21122114

2113-
defp build_payload_fields(_resource, _action, rpc_action_name, context, opts) do
2115+
defp build_payload_fields(rpc_action_name, context, opts) do
21142116
include_fields = Keyword.get(opts, :include_fields, false)
21152117
include_filtering_pagination = Keyword.get(opts, :include_filtering_pagination, true)
21162118
include_metadata_fields = Keyword.get(opts, :include_metadata_fields, false)
2117-
21182119
payload_fields = ["action: \"#{rpc_action_name}\""]
21192120

21202121
payload_fields =
@@ -2145,7 +2146,7 @@ defmodule AshTypescript.Rpc.Codegen do
21452146
if include_fields do
21462147
payload_fields ++
21472148
[
2148-
"...(config.#{formatted_fields_field()} && { #{formatted_fields_field()}: config.#{formatted_fields_field()} })"
2149+
"...(config.#{formatted_fields_field()} !== undefined && { #{formatted_fields_field()}: config.#{formatted_fields_field()} })"
21492150
]
21502151
else
21512152
payload_fields
@@ -2544,7 +2545,7 @@ defmodule AshTypescript.Rpc.Codegen do
25442545
generic_part = if generic_param != "", do: "<#{generic_param}>", else: ""
25452546

25462547
payload_fields =
2547-
build_payload_fields(resource, action, rpc_action_name, context,
2548+
build_payload_fields(rpc_action_name, context,
25482549
include_fields: has_fields,
25492550
include_metadata_fields: has_metadata
25502551
)
@@ -2553,10 +2554,49 @@ defmodule AshTypescript.Rpc.Codegen do
25532554

25542555
before_request_hook = generate_before_request_hook_call(hook_config, rpc_action_name, false)
25552556

2557+
overloads =
2558+
if action.type in [:create, :update] and has_fields do
2559+
config_type_without_fields =
2560+
config_fields
2561+
|> Enum.reject(&String.contains?(&1, "#{formatted_fields_field()}"))
2562+
|> then(fn fields -> "{\n#{Enum.join(fields, "\n")}\n}" end)
2563+
2564+
if has_metadata do
2565+
metadata_param =
2566+
"MetadataFields extends ReadonlyArray<keyof #{rpc_action_name_pascal}Metadata> = []"
2567+
2568+
"""
2569+
export async function #{function_name}<#{metadata_param}>(
2570+
config: #{config_type_without_fields}
2571+
): Promise<#{rpc_action_name_pascal}Result<[], MetadataFields>>;
2572+
export async function #{function_name}<#{metadata_param}>(
2573+
config: #{config_type_without_fields} & { #{formatted_fields_field()}: [] }
2574+
): Promise<#{rpc_action_name_pascal}Result<[], MetadataFields>>;
2575+
export async function #{function_name}<Fields extends #{rpc_action_name_pascal}Fields, #{metadata_param}>(
2576+
config: #{config_type_without_fields} & { #{formatted_fields_field()}: Fields }
2577+
): Promise<#{rpc_action_name_pascal}Result<Fields, MetadataFields>>;
2578+
"""
2579+
else
2580+
"""
2581+
export async function #{function_name}(
2582+
config: #{config_type_without_fields}
2583+
): Promise<#{rpc_action_name_pascal}Result<[]>>;
2584+
export async function #{function_name}(
2585+
config: #{config_type_without_fields} & { #{formatted_fields_field()}: never[] }
2586+
): Promise<#{rpc_action_name_pascal}Result<[]>>;
2587+
export async function #{function_name}<Fields extends #{rpc_action_name_pascal}Fields>(
2588+
config: #{config_type_without_fields} & { #{formatted_fields_field()}: Fields }
2589+
): Promise<#{rpc_action_name_pascal}Result<Fields>>;
2590+
"""
2591+
end
2592+
else
2593+
""
2594+
end
2595+
25562596
"""
25572597
#{config_type_export}#{result_type_def}
25582598
2559-
export async function #{function_name}#{generic_part}(
2599+
#{overloads}export async function #{function_name}#{generic_part}(
25602600
#{function_signature}
25612601
): Promise<#{return_type_def}> {
25622602
#{before_request_hook}
@@ -2650,7 +2690,7 @@ defmodule AshTypescript.Rpc.Codegen do
26502690

26512691
# Build the validation payload using helper (no fields or filtering/pagination for validation)
26522692
validation_payload_fields =
2653-
build_payload_fields(resource, action, rpc_action_name, context,
2693+
build_payload_fields(rpc_action_name, context,
26542694
include_fields: false,
26552695
include_filtering_pagination: false
26562696
)
@@ -2739,7 +2779,7 @@ defmodule AshTypescript.Rpc.Codegen do
27392779

27402780
# Build the payload using helper (no fields or filtering/pagination for validation)
27412781
payload_fields =
2742-
build_payload_fields(resource, action, rpc_action_name, context,
2782+
build_payload_fields(rpc_action_name, context,
27432783
include_fields: false,
27442784
include_filtering_pagination: false
27452785
)
@@ -2981,7 +3021,7 @@ defmodule AshTypescript.Rpc.Codegen do
29813021

29823022
# Build the payload using helper - include metadata_fields only when metadata is exposed
29833023
payload_fields =
2984-
build_payload_fields(resource, action, rpc_action_name, context,
3024+
build_payload_fields(rpc_action_name, context,
29853025
include_fields: has_fields,
29863026
include_metadata_fields: has_metadata_for_payload
29873027
)
@@ -3025,8 +3065,93 @@ defmodule AshTypescript.Rpc.Codegen do
30253065
end
30263066
end
30273067

3068+
# Unlike RPC functions which return Promise<Result>, channel functions use resultHandler callbacks.
3069+
# Generate overloads to enable proper type inference of the callback parameter based on fields presence.
3070+
overloads =
3071+
if action.type in [:create, :update] and has_fields do
3072+
base_config_fields =
3073+
config_fields
3074+
|> Enum.reject(
3075+
&(String.contains?(&1, "#{formatted_fields_field()}?") or
3076+
String.contains?(&1, "resultHandler:"))
3077+
)
3078+
3079+
if has_metadata do
3080+
metadata_param =
3081+
"MetadataFields extends ReadonlyArray<keyof #{rpc_action_name_pascal}Metadata> = []"
3082+
3083+
no_fields_config =
3084+
base_config_fields ++
3085+
[
3086+
" resultHandler: (result: #{rpc_action_name_pascal}Result<[], MetadataFields>) => void;"
3087+
]
3088+
3089+
no_fields_config_str = "{\n#{Enum.join(no_fields_config, "\n")}\n}"
3090+
3091+
empty_fields_config =
3092+
base_config_fields ++
3093+
[
3094+
" resultHandler: (result: #{rpc_action_name_pascal}Result<[], MetadataFields>) => void;"
3095+
]
3096+
3097+
empty_fields_config_str = "{\n#{Enum.join(empty_fields_config, "\n")}\n}"
3098+
3099+
with_fields_config =
3100+
base_config_fields ++
3101+
[
3102+
" resultHandler: (result: #{rpc_action_name_pascal}Result<Fields, MetadataFields>) => void;"
3103+
]
3104+
3105+
with_fields_config_str = "{\n#{Enum.join(with_fields_config, "\n")}\n}"
3106+
3107+
"""
3108+
export async function #{function_name}<Fields extends #{rpc_action_name_pascal}Fields, #{metadata_param}>(
3109+
config: #{with_fields_config_str} & { #{formatted_fields_field()}: Fields }
3110+
): Promise<void>;
3111+
export async function #{function_name}<#{metadata_param}>(
3112+
config: #{empty_fields_config_str} & { #{formatted_fields_field()}: [] }
3113+
): Promise<void>;
3114+
export async function #{function_name}<#{metadata_param}>(
3115+
config: #{no_fields_config_str}
3116+
): Promise<void>;
3117+
"""
3118+
else
3119+
no_fields_config =
3120+
base_config_fields ++
3121+
[" resultHandler: (result: #{rpc_action_name_pascal}Result<[]>) => void;"]
3122+
3123+
no_fields_config_str = "{\n#{Enum.join(no_fields_config, "\n")}\n}"
3124+
3125+
empty_fields_config =
3126+
base_config_fields ++
3127+
[" resultHandler: (result: #{rpc_action_name_pascal}Result<[]>) => void;"]
3128+
3129+
empty_fields_config_str = "{\n#{Enum.join(empty_fields_config, "\n")}\n}"
3130+
3131+
with_fields_config =
3132+
base_config_fields ++
3133+
[" resultHandler: (result: #{rpc_action_name_pascal}Result<Fields>) => void;"]
3134+
3135+
with_fields_config_str = "{\n#{Enum.join(with_fields_config, "\n")}\n}"
3136+
3137+
"""
3138+
export async function #{function_name}<Fields extends #{rpc_action_name_pascal}Fields>(
3139+
config: #{with_fields_config_str} & { #{formatted_fields_field()}: Fields }
3140+
): Promise<void>;
3141+
export async function #{function_name}(
3142+
config: #{empty_fields_config_str} & { #{formatted_fields_field()}: [] }
3143+
): Promise<void>;
3144+
export async function #{function_name}(
3145+
config: #{no_fields_config_str}
3146+
): Promise<void>;
3147+
"""
3148+
end
3149+
else
3150+
""
3151+
end
3152+
30283153
"""
3029-
export async function #{function_name}#{generic_part}(config: #{config_type_def}) {
3154+
#{overloads}export async function #{function_name}#{generic_part}(config: #{config_type_def}) {
30303155
#{before_channel_push_hook}
30313156
30323157
const timeout = config.timeout ?? processedConfig.timeout;

test/ts/shouldPass.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import "./shouldPass/metadata";
3232
import "./shouldPass/genericActionTypedStruct";
3333
import "./shouldPass/rpcLifecycleHooks";
3434
import "./shouldPass/noFields";
35+
import "./shouldPass/noFieldsTypeInference";
36+
import "./shouldPass/channelNoFieldsTypeInference";
3537
import "./rpcHooks";
3638

3739
// Import Zod schema validation tests
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs.contributors>
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
// Type Inference Tests for Channel Functions with Optional Fields
6+
// Validates that TypeScript correctly infers empty object type when fields are omitted for channel operations
7+
8+
import { Channel } from "phoenix";
9+
import { createUserChannel, updateUserChannel } from "../generated";
10+
11+
// Mock channel for testing
12+
declare const mockChannel: Channel;
13+
14+
// Test 1: When fields is omitted, resultHandler should receive data typed as {}
15+
createUserChannel({
16+
channel: mockChannel,
17+
input: {
18+
name: "Test User",
19+
20+
},
21+
resultHandler: (result) => {
22+
if (result.success) {
23+
// TypeScript should infer data as InferResult<UserResourceSchema, []> which is {}
24+
const data: {} = result.data;
25+
26+
// This should compile - empty object type
27+
const isEmpty = Object.keys(data).length === 0;
28+
29+
// @ts-expect-error - Should NOT have id property when no fields requested
30+
const shouldNotExist = result.data.id;
31+
}
32+
},
33+
errorHandler: (error) => console.error("Error:", error),
34+
});
35+
36+
// Test 2: When fields is explicitly [], resultHandler should also receive data typed as {}
37+
createUserChannel({
38+
channel: mockChannel,
39+
input: {
40+
name: "Test User 2",
41+
42+
},
43+
fields: [],
44+
resultHandler: (result) => {
45+
if (result.success) {
46+
// @ts-expect-error - Should NOT have name property when fields is empty
47+
const shouldNotExist = result.data.name;
48+
}
49+
},
50+
});
51+
52+
// Test 3: When fields are provided, resultHandler should receive those specific fields
53+
createUserChannel({
54+
channel: mockChannel,
55+
input: {
56+
name: "Test User 3",
57+
58+
},
59+
fields: ["id", "name"],
60+
resultHandler: (result) => {
61+
if (result.success) {
62+
// These properties SHOULD exist
63+
const id: string = result.data.id;
64+
const name: string = result.data.name;
65+
66+
// @ts-expect-error - email was not requested so should not exist on type
67+
const shouldNotExist = result.data.email;
68+
}
69+
},
70+
});
71+
72+
// Test 4: Update channel operations should have the same behavior
73+
updateUserChannel({
74+
channel: mockChannel,
75+
primaryKey: "user-id",
76+
input: {
77+
name: "Updated Name",
78+
},
79+
resultHandler: (result) => {
80+
if (result.success) {
81+
const data: {} = result.data;
82+
83+
// @ts-expect-error - Should NOT have properties when no fields
84+
const shouldNotExist = result.data.id;
85+
}
86+
},
87+
});
88+
89+
// Test 5: Update with explicit empty array
90+
updateUserChannel({
91+
channel: mockChannel,
92+
primaryKey: "user-id",
93+
input: {
94+
name: "Updated Name",
95+
},
96+
fields: [],
97+
resultHandler: (result) => {
98+
if (result.success) {
99+
// @ts-expect-error - Should NOT have properties when fields is []
100+
const shouldNotExist = result.data.name;
101+
}
102+
},
103+
});
104+
105+
// Test 6: Update with specific fields
106+
updateUserChannel({
107+
channel: mockChannel,
108+
primaryKey: "user-id",
109+
input: {
110+
name: "Updated Name",
111+
},
112+
fields: ["id", "email"],
113+
resultHandler: (result) => {
114+
if (result.success) {
115+
// These properties SHOULD exist
116+
const id: string = result.data.id;
117+
const email: string = result.data.email;
118+
119+
// @ts-expect-error - name was not requested so should not exist on type
120+
const shouldNotExist = result.data.name;
121+
}
122+
},
123+
});
124+
125+
console.log(
126+
"Channel type inference tests for optional fields compiled successfully!",
127+
);

0 commit comments

Comments
 (0)