서론
TypeScript에서 node-redis-pubsub(https://www.npmjs.com/package/node-redis-pubsub)을 사용하던 중 타입 지원이 안 되어서 답답함을 느꼈다.
그래서 @types/node-redis-pubsub을 설치해 봤는데 타입이 부정확했다.
class 형식으로 사용할 수 없었고, message가 실제로는 object인데 타입이 string으로 되어 있어서 사용하기가 불편했다.
그래서 내가 직접 d.ts 파일을 만들면서 겪은 경험을 기록한다.
이 글은 모든 원인과 해결책에 대해서 내가 깊은 이해를 하진 못 한 상태에서 작성했기 때문에 내용이 난해할 수 있다.
결론
tsconfig.json의 compilerOptions.paths에 node-redis-pubsub에 대한 경로를 추가한다.
이 때 d.ts의 파일 이름이 module 이름과 일치하지 않아도 된다.
{
"compilerOptions": {
...
"paths": {
...
"node-redis-pubsub": ["types/node-redis-pubsub.d.ts"]
}
}
}node-redis-pubsub.d.ts를 작성한다.
같은 이름으로 function과 class, namespace를 만들어주고 export = 로 내보내기를 하면 된다.
interface나 type도 같이 내보내고 싶다면 namespace 안에 만들어 주면 된다.
import * as redis from "redis"
declare function NodeRedisPubSub(
options: NodeRedisPubSub.NodeRedisPubSubOptions
): NodeRedisPubSub
declare class NodeRedisPubSub {
constructor(options: NodeRedisPubSub.NodeRedisPubSubOptions)
getRedisClient(): redis.RedisClientType
on<MessageType = any>(
channel: string,
handler: (data: MessageType, channel: string) => void,
callback?: () => void
): () => void
subscribe<MessageType = any>(
channel: string,
handler: (message: MessageType, channel: string) => void,
callback?: () => void
): () => void
emit<MessageType = any>(channel: string, message: MessageType): void
publish<MessageType = any>(channel: string, message: MessageType): void
quit(): void
end(): void
}
declare namespace NodeRedisPubSub {
interface NodeRedisPubSubOptions {
port?: number
host?: string
auth?: string
socket?: string
client?: redis.RedisClientType
redis?: redis.RedisClientType
}
}
export = NodeRedisPubSub사용할 때는 import * as NRP from ‘node-redis-pubsub’ 방식으로 사용해주면 된다.
interface나 type도 가져와서 사용할 수 있다.
import * as NRP from "node-redis-pubsub"
const redisOptions: NRP.NodeRedisPubSubOptions = {
port: 6379,
host: "localhost",
}
const nrp = new NRP(redisOptions)
type MessageType = {
id: string
name: string
description: string
}
nrp.on<MessageType>("channel", function (message, channel) {
console.log(
"message: " + message.id + " " + message.name + " " + message.description
)
console.log("channel: " + channel)
})시행 착오 과정
기존 코드
node-redis-pubsub의 코드는 이렇게 되어 있다.
https://github.com/louischatriot/node-redis-pubsub/blob/master/lib/node-redis-pubsub.js
"use strict"
var redis = require("redis")
function initClient(options) {
var auth = options.auth
var redisUrl = options.url
var client
options.port = options.port || 6379 // 6379 is Redis' default
options.host = options.host || "127.0.0.1"
if (!redisUrl) {
client = redis.createClient(options)
} else {
client = redis.createClient(redisUrl, options)
}
if (auth) {
client.auth(auth)
}
return client
}
/**
* Create a new NodeRedisPubsub instance that can subscribe to channels and publish messages
* @param {Object} options Options for the client creations:
* port - Optional, the port on which the Redis server is launched.
* scope - Optional, two NodeRedisPubsubs with different scopes will not share messages
* emitter - Optional, a redis or reds_io client
* receiver - Optionla, a redis or reds_io client
* url - Optional, a correctly formed redis connection url
*/
function NodeRedisPubsub(options) {
if (!(this instanceof NodeRedisPubsub)) {
return new NodeRedisPubsub(options)
}
options || (options = {})
// accept connections / clients having the same interface as node_redis clients
// Need to create two Redis clients as one cannot be both in receiver and emitter mode
// I wonder why that is, by the way ...
if (options.emitter) {
this.emitter = options.emitter
} else {
this.emitter = initClient(options)
}
if (options.receiver) {
this.receiver = options.receiver
} else {
this.receiver = initClient(options)
this.receiver.setMaxListeners(0)
}
delete options.url
this.prefix = options.scope ? options.scope + ":" : ""
}
/**
* Return the emitter object to be used as a regular redis client to save resources.
*/
NodeRedisPubsub.prototype.getRedisClient = function () {
return this.emitter
}
/**
* Subscribe to a channel
* @param {String} channel The channel to subscribe to, can be a pattern e.g. 'user.*'
* @param {Function} handler Function to call with the received message.
* @param {Function} cb Optional callback to call once the handler is registered.
*
*/
NodeRedisPubsub.prototype.on = NodeRedisPubsub.prototype.subscribe = function (
channel,
handler,
callback
) {
if (!callback) callback = function () {}
var self = this
if (channel === "error") {
self.errorHandler = handler
this.emitter.on("error", handler)
this.receiver.on("error", handler)
callback()
return
}
var pmessageHandler = function (pattern, _channel, message) {
if (self.prefix + channel === pattern) {
var jsonmsg = message
try {
jsonmsg = JSON.parse(message)
} catch (ex) {
if (typeof self.errorHandler === "function") {
return self.errorHandler(
"Invalid JSON received! Channel: " +
self.prefix +
channel +
" Message: " +
message
)
}
}
return handler(jsonmsg, _channel)
}
}
this.receiver.on("pmessage", pmessageHandler)
this.receiver.psubscribe(this.prefix + channel, callback)
var removeListener = function (callback) {
self.receiver.removeListener("pmessage", pmessageHandler)
return self.receiver.punsubscribe(self.prefix + channel, callback)
}
return removeListener
}
/**
* Emit an event
* @param {String} channel Channel on which to emit the message
* @param {Object} message
*/
NodeRedisPubsub.prototype.emit = NodeRedisPubsub.prototype.publish = function (
channel,
message
) {
return this.emitter.publish(this.prefix + channel, JSON.stringify(message))
}
/**
* Safely close the redis connections 'soon'
*/
NodeRedisPubsub.prototype.quit = function () {
this.emitter.quit()
this.receiver.quit()
}
/**
* Dangerously close the redis connections immediately
*/
NodeRedisPubsub.prototype.end = function () {
this.emitter.end(true)
this.receiver.end(true)
}
module.exports = NodeRedisPubsub@types/node-redis-pubsub의 코드는 이렇게 되어 있다.
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node-redis-pubsub/index.d.ts
import * as redis from "redis"
declare function NRP(options: object): NRP.NodeRedisPubSub
declare namespace NRP {
function initClient(options: object): NodeRedisPubSub
class NodeRedisPubSub {
constructor(options: object)
getRedisClient(): redis.RedisClient
on(
channel: string,
handler: (data: string, channel: string) => void,
callback?: () => void
): () => void
subscribe(
channel: string,
handler: (data: string, channel: string) => void,
callback?: () => void
): () => void
emit(channel: string, message: string): void
publish(channel: string, message: string): void
quit(): void
end(): void
}
}
export = NRP방법 1
내가 작성한 Declaration 코드 - node-redis-pubsub.d.ts
declare module "node-redis-pubsub" {
import { RedisClient } from "redis"
interface NodeRedisPubsubOptions {
emitter?: RedisClient
receiver?: RedisClient
scope?: string
url?: string
port?: number
host?: string
auth?: string
}
class NodeRedisPubsub {
constructor(options?: NodeRedisPubsubOptions)
getRedisClient(): RedisClient
on(
channel: string,
handler: (message: any, channel: string) => void,
callback?: () => void
): () => void
emit(channel: string, message: any): boolean
quit(): void
end(): void
}
// export 방식과 import하는 방식 중요
export = NodeRedisPubsub
}대신 import 하여 사용할 때 주의할 점이 있다.
tsconfig.json의 esModuleInterop 속성을 false로 설정한다.
import할 때는 import NRP = require("node-redis-pubsub") 같은 방식으로 import 한다.
- 해당 구문은 TypeScript에서 CommonJS 스타일의 모듈을 가져오는 방식 중 하나이다.
- https://www.typescriptlang.org/docs/handbook/2/modules.html#es-module-syntax-with-commonjs-behavior
- 이렇게 해야하는 이유는 모듈의 JavaScript코드에서 모듈을 export 할 때
module.exports = NodeRedisPubsub를 사용했기 때문이다.
사용 예시
import NRP = require("node-redis-pubsub")
const redisOptions = {
port: 6379,
host: "localhost",
}
const nrp = new NRP(redisOptions)
nrp.on("channel", function (message, channel) {
console.log("message: " + message)
console.log("channel: " + channel)
})Declaration File을 수정해서 타입을 정교하게 지정할 수도 있다.
...
on<MessageType = any>(
channel: string,
handler: (message: MessageType, channel: string) => void,
callback?: () => void
): () => void;
......
type MessageType = {
id: string;
name: string;
description: string;
};
nrp.on<MessageType>("channel", function (message, channel) {
console.log(
"message: " + message.id + " " + message.name + " " + message.description
);
console.log("channel: " + channel);
});과정
Declaration File을 작성할 때 내가 원한 두 가지 조건이 있다.
- 기존 JavaScript 버전에서 모듈을 사용하던 것과 같이 class 형식(
new NRP)으로 사용할 수 있어야 한다. tsconfig의esModuleInterop속성을false로 두고 사용할 수 있어야 한다.
처음에는 ESM 방식으로 불러오는 것을 생각하며 Declaration File에서 export를 export default NodeRedisPubsub로 작성하고 import from으로 사용해봤다. TypeScript Compile에서는 오류가 표시되지 않았다.
하지만 실행해보니까 TypeError: node_redis_pubsub_1.default is not a constructor 오류가 발생했다.
이런 방식으로 import해서 사용하려면 esModuleInterop을 true로 설정해야한다
하지만 false로 하기로 했기 때문에 이 방법은 사용하지 않고 다른 방법을 찾아보았다.
import * as NRP from "node-redis-pubsub" 이런 방식으로 import를 해보면 TypeScript Compile 오류가 발생했다.
This expression is not constructable.
Type 'typeof import("node-redis-pubsub")' has no construct signatures.
그래서 결국 export = NodeRedisPubsub 처럼 내보내기를 변경하고
import NRP = require("node-redis-pubsub") 방식으로 import 하였다.
방법 2를 완성하고 나니까 방법 1에 적용할 아이디어가 생겼다.
원래는 export default로 내보내기를 하면 import할 때 * as NRP로 해야하기 때문에 사용할 때 NRP.default로 사용을 하도록 오류메시지가 출력되어서 export = 방식으로 export를 했던 것이고 그렇게 export를 하다 보니까 import할 때도 import와 require를 섞어서 쓰는 방식을 사용해야 했다.
namespace를 넣어주니까 export = 로 export를 해도 * as NRP 방식으로 import를 할 수 있게 되었다.
declare module "node-redis-pubsub" {
...
namespace NodeRedisPubsub {}
export = NodeRedisPubsub;
}방법 2
결론에 있는 방법이다.
tsconfig.json에 compilerOptions.paths 추가하여 해당 모듈에 대하여 Type Declaration을 덮어 씌운다. 이 방법은 declare module 같은 것을 안 해줘도 되고 @types에 들어가는 타입 정의처럼 작성해줘도 되어서 타입을 PR 할 것까지 고려하면 편리한 방식이다.
이 방법의 장점은 declare module을 안 써도 된다는 것이다. @types에 들어가는 타입 정의처럼 작성하면 된다.
이 방법으로 타입 정의를 해서 사용하다가 해당 type 소스에 PR하고 merge되면 이 부분만 제거하고 타입 가져와서 사용하면 된다.
이 방법을 할 때 export와 import 하는 방식 때문에 시행착오를 많이 했는데 답은 namespace에 있었다.
향후 학습하면 좋을 것
- 왜 namespace를 사용하면 export, import 문제가 해결이 되는지 이론적으로 좀 더 알아보면 좋을 것 같다.