Skip to content

Commit 4788873

Browse files
committed
Infer concrete types from constructor TypeClass forms
1 parent cd5f5b1 commit 4788873

7 files changed

Lines changed: 148 additions & 19 deletions

File tree

demo/typescript/topics/src/publisher.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
*
44
* This demo shows how to create a ROS2 publisher using TypeScript
55
* with rclnodejs. It publishes string messages to the "ts_demo" topic.
6+
*
7+
* It also demonstrates the two equivalent, fully type-safe ways to
8+
* identify a message type:
9+
* - String name: node.createPublisher('std_msgs/msg/String', ...)
10+
* - Message class: node.createPublisher(StringMsg, ...)
11+
*
12+
* With the class form (obtained from `rclnodejs.require(...)`), TypeScript
13+
* infers the publisher's message type directly from the constructor, so
14+
* `new StringMsg()` is typed without any explicit annotation.
615
*/
716

817
import * as rclnodejs from 'rclnodejs';
@@ -25,22 +34,35 @@ async function main(): Promise<void> {
2534
const node = new rclnodejs.Node('ts_publisher_demo');
2635
console.log(`✓ Created node: ${node.getFullyQualifiedName()}`);
2736

28-
// Create a publisher for std_msgs/msg/String messages
37+
// Style A — string name. Create a publisher for std_msgs/msg/String
38+
// messages, building each message with `createMessageObject`:
2939
const publisher = node.createPublisher('std_msgs/msg/String', TOPIC_NAME);
3040
console.log(`✓ Created publisher on topic: ${TOPIC_NAME}`);
3141

42+
// Style B — message class. Obtain the constructor with
43+
// `rclnodejs.require(...)` and pass it directly. The publisher's message
44+
// type is inferred from the constructor, and `new StringMsg()` is typed
45+
// without any annotation. Both styles are equally type-safe.
46+
const StringMsg = rclnodejs.require('std_msgs/msg/String');
47+
const typedPublisher = node.createPublisher(StringMsg, TOPIC_NAME);
48+
3249
// Message counter
3350
let messageCount = 0;
3451

3552
// Create a timer to publish messages at regular intervals
3653
const timer = node.createTimer(PUBLISH_INTERVAL, () => {
37-
// Create a string message
54+
// Style A — build the message with createMessageObject:
3855
const message = rclnodejs.createMessageObject('std_msgs/msg/String');
3956
message.data = `Hello from TypeScript publisher! Message #${++messageCount} at ${new Date().toISOString()}`;
40-
41-
// Publish the message
4257
publisher.publish(message);
43-
console.log(`📤 Published: "${message.data}"`);
58+
console.log(`📤 [A/string] Published: "${message.data}"`);
59+
60+
// Style B — build the message by instantiating the class. The
61+
// `data` field is typed from the inferred message type.
62+
const typedMessage = new StringMsg();
63+
typedMessage.data = `Hello from typed publisher! Message #${messageCount}`;
64+
typedPublisher.publish(typedMessage);
65+
console.log(`📤 [B/class] Published: "${typedMessage.data}"`);
4466
});
4567

4668
console.log(`✓ Created timer with ${PUBLISH_INTERVAL}ms interval`);

demo/typescript/topics/src/subscriber.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
*
44
* This demo shows how to create a ROS2 subscriber using TypeScript
55
* with rclnodejs. It subscribes to string messages from the "ts_demo" topic.
6+
*
7+
* It also demonstrates the two equivalent, fully type-safe ways to
8+
* identify a message type:
9+
* - String name: node.createSubscription('std_msgs/msg/String', ...)
10+
* - Message class: node.createSubscription(StringMsg, ...)
11+
*
12+
* With the class form (obtained from `rclnodejs.require(...)`), TypeScript
13+
* infers the callback's message type directly from the constructor, so no
14+
* explicit `message` annotation is needed.
615
*/
716

817
import * as rclnodejs from 'rclnodejs';
@@ -27,17 +36,30 @@ async function main(): Promise<void> {
2736
// Message counter
2837
let receivedCount = 0;
2938

30-
// Create a subscription for std_msgs/msg/String messages
39+
// Create a subscription for std_msgs/msg/String messages.
40+
//
41+
// Style A — string name. The callback message type is annotated
42+
// explicitly as rclnodejs.std_msgs.msg.String:
3143
const subscription = node.createSubscription(
3244
'std_msgs/msg/String',
3345
TOPIC_NAME,
3446
(message: rclnodejs.std_msgs.msg.String) => {
3547
receivedCount++;
36-
console.log(`📥 [${receivedCount}] Received: "${message.data}"`);
37-
console.log(` Timestamp: ${new Date().toISOString()}`);
48+
console.log(
49+
`📥 [A/string] [${receivedCount}] Received: "${message.data}"`
50+
);
3851
}
3952
);
4053

54+
// Style B — message class. Obtain the constructor with
55+
// `rclnodejs.require(...)` and pass it directly. The `message`
56+
// parameter type is inferred as rclnodejs.std_msgs.msg.String, so no
57+
// explicit annotation is required. Both styles are equally type-safe.
58+
const StringMsg = rclnodejs.require('std_msgs/msg/String');
59+
node.createSubscription(StringMsg, TOPIC_NAME, (message) => {
60+
console.log(`📥 [B/class] Received: "${message.data}"`);
61+
});
62+
4163
console.log(`✓ Created subscription on topic: ${TOPIC_NAME}`);
4264
console.log('👂 Subscriber is listening. Press Ctrl+C to stop...\n');
4365

rostsd_gen/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function savePkgInfoAsTSD(pkgInfos, fd) {
164164
fs.writeSync(fd, ' type Message = MessagesMap[MessageTypeClassName];\n');
165165
fs.writeSync(
166166
fd,
167-
' type MessageType<T> = T extends MessageTypeClassName ? MessagesMap[T] : object;\n\n'
167+
' type MessageType<T> = T extends MessageTypeClassName ? MessagesMap[T] : T extends new (...args: any[]) => infer R ? R : object;\n\n'
168168
);
169169

170170
// write message contructor mappings
@@ -193,7 +193,7 @@ function savePkgInfoAsTSD(pkgInfos, fd) {
193193
fs.writeSync(fd, ' type Service = ServicesMap[ServiceTypeClassName];\n');
194194
fs.writeSync(
195195
fd,
196-
' type ServiceType<T> = T extends ServiceTypeClassName ? ServicesMap[T] : object;\n\n'
196+
' type ServiceType<T> = T extends ServiceTypeClassName ? ServicesMap[T] : T extends { Request: any; Response: any } ? T : object;\n\n'
197197
);
198198

199199
// write actions type mappings
@@ -206,7 +206,7 @@ function savePkgInfoAsTSD(pkgInfos, fd) {
206206
fs.writeSync(fd, ' type Action = ActionsMap[ActionTypeClassName];\n');
207207
fs.writeSync(
208208
fd,
209-
' type ActionType<T> = T extends ActionTypeClassName ? ActionsMap[T] : object;\n\n'
209+
' type ActionType<T> = T extends ActionTypeClassName ? ActionsMap[T] : T extends { Goal: any; Feedback: any; Result: any } ? T : object;\n\n'
210210
);
211211

212212
fs.writeSync(

test/types/index.test-d.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,24 @@ node.createPublisher(TYPE_CLASS, TOPIC, publisher.options, (event: object) => {
211211
expectType<void>(publisher.assertLiveliness());
212212
expectType<string>(publisher.loggerName);
213213

214+
// ---- TypeClass forms ----
215+
// Case 1: a message/service class constructor (e.g. the value returned by rclnodejs.require)
216+
const StringClass = rclnodejs.require('std_msgs/msg/String');
217+
expectAssignable<rclnodejs.TypeClass>(StringClass);
218+
const publisherFromCtor = node.createPublisher(StringClass, TOPIC);
219+
expectType<rclnodejs.Publisher<typeof StringClass>>(publisherFromCtor);
220+
221+
// Case 2: a string representing the message class name
222+
expectAssignable<rclnodejs.TypeClass>(TYPE_CLASS);
223+
const publisherFromString = node.createPublisher(TYPE_CLASS, TOPIC);
224+
expectType<rclnodejs.Publisher<'std_msgs/msg/String'>>(publisherFromString);
225+
226+
// Case 3: an object descriptor of the message class
227+
const typeDescriptor = { package: 'std_msgs', type: 'msg', name: 'String' };
228+
expectAssignable<rclnodejs.TypeClass>(typeDescriptor);
229+
const publisherFromDescriptor = node.createPublisher(typeDescriptor, TOPIC);
230+
expectType<rclnodejs.Publisher<typeof typeDescriptor>>(publisherFromDescriptor);
231+
214232
// ---- LifecyclePublisher ----
215233
const lifecyclePublisher = lifecycleNode.createLifecyclePublisher(
216234
TYPE_CLASS,
@@ -343,6 +361,30 @@ expectType<void>(
343361
expectType<boolean>(client.isDestroyed());
344362
expectType<string>(client.loggerName);
345363

364+
// ---- Service/Client constructor-form typing ----
365+
// Passing a service constructor (the value returned by rclnodejs.require) should
366+
// infer the concrete request/response message types, not fall back to `object`.
367+
const AddTwoInts = rclnodejs.require('example_interfaces/srv/AddTwoInts');
368+
const serviceFromCtor = node.createService(
369+
AddTwoInts,
370+
'add_two_ints_ctor',
371+
(request) => {
372+
expectType<rclnodejs.example_interfaces.srv.AddTwoInts_Request>(request);
373+
}
374+
);
375+
expectType<rclnodejs.example_interfaces.srv.AddTwoIntsConstructor>(
376+
serviceFromCtor
377+
);
378+
379+
const clientFromCtor = node.createClient(AddTwoInts, 'add_two_ints_ctor');
380+
expectType<rclnodejs.Client<typeof AddTwoInts>>(clientFromCtor);
381+
clientFromCtor.sendRequest(new AddTwoInts.Request(), (response) => {
382+
expectType<rclnodejs.example_interfaces.srv.AddTwoInts_Response>(response);
383+
});
384+
expectType<Promise<rclnodejs.example_interfaces.srv.AddTwoInts_Response>>(
385+
clientFromCtor.sendRequestAsync(new AddTwoInts.Request())
386+
);
387+
346388
// ---- Timer ----
347389
const timerCallback: rclnodejs.TimerRequestCallback = (timerInfo) => {
348390
if (timerInfo) {
@@ -602,6 +644,25 @@ expectType<void>(
602644
)
603645
);
604646

647+
// ---- Action constructor-form typing ----
648+
// Passing an action constructor (the value returned by rclnodejs.require) should
649+
// infer the concrete goal/feedback/result message types, not fall back to `object`.
650+
const actionClientFromCtor = new rclnodejs.ActionClient(
651+
node,
652+
Fibonacci,
653+
'fibonacci_ctor'
654+
);
655+
expectType<rclnodejs.ActionClient<typeof Fibonacci>>(actionClientFromCtor);
656+
expectType<rclnodejs.example_interfaces.action.Fibonacci_Goal>(
657+
{} as rclnodejs.ActionGoal<typeof Fibonacci>
658+
);
659+
expectType<rclnodejs.example_interfaces.action.Fibonacci_Feedback>(
660+
{} as rclnodejs.ActionFeedback<typeof Fibonacci>
661+
);
662+
expectType<rclnodejs.example_interfaces.action.Fibonacci_Result>(
663+
{} as rclnodejs.ActionResult<typeof Fibonacci>
664+
);
665+
605666
// ---- ActionUuid -----
606667
const actionUuid = new rclnodejs.ActionUuid();
607668
expectType<rclnodejs.ActionUuid>(actionUuid);

types/action_client.d.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
declare module 'rclnodejs' {
22
type ActionGoal<T> = T extends ActionTypeClassName
33
? InstanceType<ActionsMap[T]['Goal']>
4-
: object;
4+
: T extends { Goal: new (...args: any[]) => infer R }
5+
? R
6+
: object;
57
type ActionFeedback<T> = T extends ActionTypeClassName
68
? InstanceType<ActionsMap[T]['Feedback']>
7-
: object;
9+
: T extends { Feedback: new (...args: any[]) => infer R }
10+
? R
11+
: object;
812
type ActionResult<T> = T extends ActionTypeClassName
913
? InstanceType<ActionsMap[T]['Result']>
10-
: object;
14+
: T extends { Result: new (...args: any[]) => infer R }
15+
? R
16+
: object;
1117

1218
/**
1319
* Goal handle for working with Action Clients.

types/node.d.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,24 @@ declare module 'rclnodejs' {
33
* Identifies type of ROS message such as msg or srv.
44
*/
55
type TypeClass<T = TypeClassName> =
6-
| (() => any)
7-
| T // a string representing the message class, e.g. 'std_msgs/msg/String',
6+
| (new (...args: any[]) => any) // a message class constructor, e.g. the value returned by rclnodejs.require('std_msgs/msg/String')
87
| {
9-
// object representing a message class, e.g. {package: 'std_msgs', type: 'msg', name: 'String'}
8+
// a service class constructor, e.g. the value returned by rclnodejs.require('example_interfaces/srv/AddTwoInts')
9+
readonly Request: new (...args: any[]) => any;
10+
readonly Response: new (...args: any[]) => any;
11+
}
12+
| {
13+
// an action class constructor, e.g. the value returned by rclnodejs.require('example_interfaces/action/Fibonacci')
14+
readonly Goal: new (...args: any[]) => any;
15+
readonly Feedback: new (...args: any[]) => any;
16+
readonly Result: new (...args: any[]) => any;
17+
}
18+
| T // a string representing the message/service/action class, e.g. 'std_msgs/msg/String',
19+
| {
20+
// object representing a message class, e.g. {package: 'std_msgs', type: 'msg', name: 'String'}.
21+
// NOTE: this form is supported at runtime but provides no message type inference in
22+
// TypeScript (messages resolve to `object`). Prefer the string or constructor form for
23+
// full type safety.
1024
package: string;
1125
type: string;
1226
name: string;

types/service.d.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
declare module 'rclnodejs' {
22
type ServiceRequestMessage<T> = T extends ServiceTypeClassName
33
? InstanceType<ServicesMap[T]['Request']>
4-
: object;
4+
: T extends { Request: new (...args: any[]) => infer R }
5+
? R
6+
: object;
57

68
type ServiceResponseMessage<T> = T extends ServiceTypeClassName
79
? InstanceType<ServicesMap[T]['Response']>
8-
: object;
10+
: T extends { Response: new (...args: any[]) => infer R }
11+
? R
12+
: object;
913

1014
/**
1115
* Callback for receiving service requests from a client.

0 commit comments

Comments
 (0)