JSDoc으로 타입 정보 표현하기

JSDoc으로 타입 정보 표현하기 (JSDoc Reference)

JavaScript 파일에 타입 정보를 담아 쓰는 방법을 정리해 둔 문서예요. TypeScript 컴파일러가 JSDoc 주석에 적힌 타입 힌트를 읽어서 타입 검사를 해 줘요. 이 페이지는 그중에서 "어떤 태그가 현재 지원되는지"를 한눈에 보여주고, 각 태그가 TypeScript에서 어떻게 동작하는지 예시와 함께 설명해 줍니다.

출처: TypeScript 공식문서

본문

JavaScript 파일에 JSDoc 주석을 써서 타입 정보를 제공할 때, 현재 어떤 구성 요소(construct)가 지원되는지 목록으로 정리한 문서예요.

참고 사항:

  • 아래 목록에 명시적으로 나오지 않은 태그(가령 @async 같은 건)는 아직 지원되지 않아요.
  • TypeScript 파일에서는 문서화용 태그(documentation tags)만 지원돼요. 그 외 태그들은 전부 JavaScript 파일에서만 동작해요.

Types

Classes

Documentation

문서화 태그는 TypeScript와 JavaScript 양쪽에서 모두 동작해요.

Other

전반적으로 의미는 jsdoc.app에 나온 태그 의미와 같거나, 그보다 조금 더 넓은 상위 집합(superset)이라고 보면 돼요. 아래 코드는 각 태그가 어떻게 다르고, 어떤 식으로 쓰이는지 예시를 들어 설명해 줍니다.

참고: 플레이그라운드에서 JSDoc 지원을 직접 실험해 볼 수 있어요.

Types

@type

@type 태그로 타입을 지정할 수 있어요. 그 타입은 다음 중 하나일 수 있어요.

  1. 원시 타입(primitive), 예를 들면 string이나 number.
  2. 전역이든 import든, TypeScript 선언에 정의된 타입.
  3. JSDoc @typedef 태그로 선언한 타입.

JSDoc 타입 문법 대부분과 TypeScript 문법 전부를 쓸 수 있답니다. 가장 기본적인 string부터 조건부 타입 같은 최신 기능까지요.

/** @type {string} */
var s;
/** @type {Window} */
var win;
/** @type {PromiseLike<string>} */
var promisedString;
// You can specify an HTML Element with DOM properties
/** @type {HTMLElement} */
var myElement = document.querySelector(selector);
element.dataset.myData = "";

@type에 유니언 타입도 줄 수 있어요. 예를 들어 값이 string이 될 수도, boolean이 될 수도 있다면 이렇게 쓰면 돼요.

/** @type {string | boolean} */
var sb;

배열 타입은 여러 문법으로 지정할 수 있어요.

/** @type {number[]} */
var ns;
/** @type {Array.<number>} */
var jsdoc;
/** @type {Array<number>} */
var nas;

객체 리터럴 타입도 지정할 수 있어요. 'a'(string)와 'b'(number) 프로퍼티를 가진 객체라면 이렇게 쓰죠.

/** @type {{ a: string, b: number }} */
var var9;

맵(map)처럼 생긴 객체나 배열(array)처럼 생긴 객체는 string·number 인덱스 시그니처로 지정할 수 있어요. 표준 JSDoc 문법이든 TypeScript 문법이든 상관없어요.

/**
 * A map-like object that maps arbitrary `string` properties to `number`s.
 *
 * @type {Object.<string, number>}
 */
var stringToNumber;

/** @type {Object.<number, object>} */
var arrayLike;

위 두 타입은 TypeScript 타입 { [x: string]: number }{ [x: number]: any }와 동일해요. 컴파일러는 두 문법을 모두 이해합니다.

함수 타입도 TypeScript 문법이나 Google Closure 문법으로 지정할 수 있어요.

/** @type {function(string, boolean): number} Closure syntax */
var sbn;
/** @type {(s: string, b: boolean) => number} TypeScript syntax */
var sbn2;

아니면 그냥 타입을 정하지 않은 Function 타입을 써도 되고요.

/** @type {Function} */
var fn7;
/** @type {function} */
var fn6;

Closure에서 온 다른 타입들도 동작해요.

/**
 * @type {*} - can be 'any' type
 */
var star;
/**
 * @type {?} - unknown type (same as 'any')
 */
var question;
Casts

TypeScript는 캐스트(cast) 문법을 Google Closure에서 가져왔어요. 괄호로 감싼 어떤 표현식 앞에 @type 태그를 달아서 타입을 다른 타입으로 캐스팅할 수 있죠.

/**
 * @type {number | string}
 */
var numberOrString = Math.random() < 0.5 ? "hello" : 100;
var typeAssertedNumber = /** @type {number} */ (numberOrString);

TypeScript에서처럼 const로 캐스팅하는 것도 가능해요.

let one = /** @type {const} */(1);
Import types

import types로 다른 파일에서 선언을 가져올 수 있어요. 이 문법은 TypeScript 전용이고, JSDoc 표준과는 조금 달라요.

// @filename: types.d.ts
export type Pet = {
  name: string,
};

// @filename: main.js
/**
 * @param {import("./types").Pet} p
 */
function walk(p) {
  console.log(`Walking ${p.name}...`);
}

import types는 어떤 모듈에서 값의 타입을 모를 때, 혹은 그 타입이 커서 일일이 쓰기 번거로울 때 값의 타입을 가져오는 데 쓸 수 있어요.

/**
 * @type {typeof import("./accounts").userAccount}
 */
var x = require("./accounts").userAccount;

@import

@import 태그는 다른 파일의 export를 참조하게 해 줘요.

/**
 * @import {Pet} from "./types"
 */

/**
 * @type {Pet}
 */
var myPet;
myPet.name;

이 태그들은 런타임에 실제로 파일을 import 하지는 않아요. 이 태그로 스코프에 들어온 심볼은 타입 검사를 위해서 JSDoc 주석 안에서만 쓸 수 있답니다.

// @filename: dog.js
export class Dog {
  woof() {
    console.log("Woof!");
  }
}

// @filename: main.js
/** @import { Dog } from "./dog.js" */

const d = new Dog(); // error!

@param and @returns

@param@type과 같은 타입 문법을 쓰되, 파라미터 이름이 하나 더 붙어요. 파라미터 이름을 대괄호로 감싸면 선택적(optional) 파라미터로 선언할 수도 있어요.

// Parameters may be declared in a variety of syntactic forms
/**
 * @param {string}  p1 - A string param.
 * @param {string=} p2 - An optional param (Google Closure syntax)
 * @param {string} [p3] - Another optional param (JSDoc syntax).
 * @param {string} [p4="test"] - An optional param with a default value
 * @returns {string} This is the result
 */
function stringsStringStrings(p1, p2, p3, p4) {
  // TODO
}

함수의 반환 타입도 마찬가지예요.

/**
 * @return {PromiseLike<string>}
 */
function ps() {}

/**
 * @returns {{ a: string, b: number }} - May use '@returns' as well as '@return'
 */
function ab() {}

@typedef, @callback, and @param

@typedef로 복잡한 타입을 정의할 수 있어요. @param도 비슷한 문법을 써요.

/**
 * @typedef {Object} SpecialType  - creates a new type named 'SpecialType'
 * @property {string} prop1 - a string property of SpecialType
 * @property {number} prop2 - a number property of SpecialType
 * @property {number=} prop3 - an optional number property of SpecialType
 * @prop {number} [prop4] - an optional number property of SpecialType
 * @prop {number} [prop5=42] - an optional number property of SpecialType with default
 */

/** @type {SpecialType} */
var specialTypeObject;
specialTypeObject.prop3;

첫 줄에는 objectObject둘 다 쓸 수 있어요.

/**
 * @typedef {object} SpecialType1 - creates a new type named 'SpecialType1'
 * @property {string} prop1 - a string property of SpecialType1
 * @property {number} prop2 - a number property of SpecialType1
 * @property {number=} prop3 - an optional number property of SpecialType1
 */

/** @type {SpecialType1} */
var specialTypeObject1;

@param은 일회용(one-off) 타입 지정에 비슷한 문법을 제공해요. 이때 중첩된 프로퍼티 이름 앞에는 반드시 파라미터 이름을 붙여야 한다는 점을 기억하세요.

/**
 * @param {Object} options - The shape is the same as SpecialType above
 * @param {string} options.prop1
 * @param {number} options.prop2
 * @param {number=} options.prop3
 * @param {number} [options.prop4]
 * @param {number} [options.prop5=42]
 */
function special(options) {
  return (options.prop4 || 1001) + options.prop5;
}

@callback@typedef와 비슷한데, 객체 타입 대신 함수 타입을 지정해요.

/**
 * @callback Predicate
 * @param {string} data
 * @param {number} [index]
 * @returns {boolean}
 */

/** @type {Predicate} */
const ok = (s) => !(s.length % 2);

물론 이런 타입들 전부 한 줄짜리 @typedef 안에서 TypeScript 문법으로도 선언할 수 있어요.

/** @typedef {{ prop1: string, prop2: string, prop3?: number }} SpecialType */
/** @typedef {(data: string, index?: number) => boolean} Predicate */

@template

@template 태그로 타입 파라미터를 선언할 수 있어요. 이걸로 함수·클래스·타입을 **제네릭(generic)**으로 만들 수 있죠.

/**
 * @template T
 * @param {T} x - A generic parameter that flows through to the return type
 * @returns {T}
 */
function id(x) {
  return x;
}

const a = id("string");
const b = id(123);
const c = id({});

타입 파라미터를 여러 개 선언하려면 콤마로 구분하거나 태그를 여러 번 쓰면 돼요.

/**
 * @template T,U,V
 * @template W,X
 */

타입 파라미터 이름 앞에 타입 제약(constraint)을 붙일 수도 있어요. 다만 목록에서 첫 번째 타입 파라미터만 제약을 받아요.

/**
 * @template {string} K - K must be a string or string literal
 * @template {{ serious(): string }} Seriousalizable - must have a serious method
 * @param {K} key
 * @param {Seriousalizable} object
 */
function seriousalize(key, object) {
  // ????
}

마지막으로, 타입 파라미터의 기본값을 지정할 수도 있어요.

/** @template [T=object] */
class Cache {
    /** @param {T} initial */
    constructor(initial) {
    }
}
let c = new Cache()

@satisfies

@satisfies는 TypeScript의 후치(postfix) 연산자 satisfies를 JSDoc에서 쓸 수 있게 해 줘요. satisfies는 어떤 값이 특정 타입을 구현한다는 것을 선언하되, 그 값 자체의 타입은 바꾸지 않아요.

// @ts-check
/**
 * @typedef {"hello world" | "Hello, world"} WelcomeMessage
 */

/** @satisfies {WelcomeMessage} */
const message = "hello world"

/** @satisfies {WelcomeMessage} */
const failingMessage = "Hello world!" // error: Type '"Hello world!"' does not satisfy the expected type 'WelcomeMessage'.

/** @type {WelcomeMessage} */
const messageUsingType = "hello world"

Classes

클래스는 ES6 클래스로 선언할 수 있어요.

class C {
  /**
   * @param {number} data
   */
  constructor(data) {
    // property types can be inferred
    this.name = "foo";

    // or set explicitly
    /** @type {string | null} */
    this.title = null;

    // or simply annotated, if they're set elsewhere
    /** @type {number} */
    this.size;

    this.initialize(data); // Should error, initializer expects a string
  }
  /**
   * @param {string} s
   */
  initialize = function (s) {
    this.size = s.length;
  };
}

var c = new C(0);

// C should only be called with new, but
// because it is JavaScript, this is allowed and
// considered an 'any'.
var result = C(1);

혹은 생성자 함수(constructor function)로 선언할 수도 있어요. 그럴 땐 @constructor@this와 함께 쓰면 돼요.

Property Modifiers

@public, @private, @protected는 TypeScript의 public, private, protected와 똑같이 동작해요.

// @ts-check

class Car {
  constructor() {
    /** @private */
    this.identifier = 100;
  }

  printIdentifier() {
    console.log(this.identifier);
  }
}

const c = new Car();
console.log(c.identifier); // error: Property 'identifier' is private and only accessible within class 'Car'.
  • @public은 항상 암시되는 접근자라 생략할 수 있어요. 어떤 곳에서든 그 프로퍼티에 접근할 수 있다는 뜻이에요.
  • @private은 그 프로퍼티를 담고 있는 클래스 안에서만 쓸 수 있다는 뜻이에요.
  • @protected는 담고 있는 클래스와 그 파생 서브클래스 안에서만 쓰되, 담고 있는 클래스의 다른 인스턴스에서는 쓸 수 없다는 뜻이에요.

@public, @private, @protected는 생성자 함수(constructor functions)에서는 동작하지 않아요.

@readonly

@readonly 수정자는 프로퍼티가 초기화할 때만 쓰여지도록 보장해 줘요.

// @ts-check

class Car {
  constructor() {
    /** @readonly */
    this.identifier = 100;
  }

  printIdentifier() {
    console.log(this.identifier);
  }
}

const c = new Car();
console.log(c.identifier);

@override

@override는 TypeScript에서와 똑같이 동작해요. 기반 클래스의 메서드를 오버라이드하는 메서드에 붙여 주면 됩니다.

export class C {
  m() { }
}
class D extends C {
  /** @override */
  m() { }
}

오버라이드 검사를 켜려면 tsconfig에 noImplicitOverride: true를 설정해 주세요.

@extends

JavaScript 클래스가 제네릭 기반 클래스를 상속할 때, 타입 인자를 넘겨줄 JavaScript 문법은 따로 없어요. @extends 태그가 그 역할을 해 줘요.

/**
 * @template T
 * @extends {Set<T>}
 */
class SortableSet extends Set {
  // ...
}

@extends클래스에서만 동작한다는 점을 기억하세요. 지금은 생성자 함수가 클래스를 상속할 방법이 없어요.

@implements

마찬가지로, TypeScript 인터페이스를 구현할 JavaScript 문법도 따로 없어요. @implements 태그가 TypeScript에서처럼 동작합니다.

/** @implements {Print} */
class TextBook {
  print() {
    // TODO
  }
}

@constructor

컴파일러는 this 프로퍼티 할당을 보고 생성자 함수를 추론할 수 있어요. 하지만 @constructor 태그를 추가하면 검사를 더 엄격하게, 제안(suggestion)을 더 정확하게 만들 수 있어요.

/**
 * @constructor
 * @param {number} data
 */
function C(data) {
  // property types can be inferred
  this.name = "foo";

  // or set explicitly
  /** @type {string | null} */
  this.title = null;

  // or simply annotated, if they're set elsewhere
  /** @type {number} */
  this.size;

  this.initialize(data); // error: Argument of type 'number' is not assignable to parameter of type 'string'.
}
/**
 * @param {string} s
 */
C.prototype.initialize = function (s) {
  this.size = s.length;
};

var c = new C(0);
c.size;

var result = C(1); // error: Value of type 'typeof C' is not callable. Did you mean to include 'new'?

참고: 이런 오류 메시지는 JSConfigcheckJs가 켜진 JS 코드베이스에서만 표시돼요.

@constructor를 쓰면 생성자 함수 C 안에서 this를 검사해 줘요. 그래서 initialize 메서드에 대한 제안을 받고, 숫자를 넘기면 오류가 나요. 또 C를 호출(생성) 대신 사용하면 에디터가 경고를 보여줄 수도 있어요.

다만 아쉽게도, 이 때문에 호출도 가능한 생성자 함수(callable인 constructor)는 @constructor를 쓸 수 없어요.

@this

컴파일러는 문맥(context)이 있으면 보통 this의 타입을 알아내요. 문맥이 없을 때는 @thisthis의 타입을 직접 지정해 주면 됩니다.

/**
 * @this {HTMLElement}
 * @param {*} e
 */
function callbackForLater(e) {
  this.clientHeight = parseInt(e); // should be fine!
}

Documentation

@deprecated

함수·메서드·프로퍼티가 deprecated(더 이상 권장하지 않음)될 때, /** @deprecated */ JSDoc 주석을 달아서 사용자에게 알릴 수 있어요. 이 정보는 자동완성 목록과 제안 진단(suggestion diagnostic)으로 표시되고, 에디터가 특별하게 처리할 수 있어요. VS Code 같은 에디터에서는 보통 취소선 스타일로 표시돼요.

/** @deprecated */
const apiV1 = {};
const apiV2 = {};

apiV;

@see

@see는 프로그램 안의 다른 이름으로 링크를 걸어 줘요.

/** @see Box for implementation details */
type Boxify<T> = { [K in keyof T]: Box<T> };

일부 에디터는 Box를 링크로 바꿔서, 그쪽으로 점프했다가 다시 돌아오기 쉽게 만들어 줘요.

@link

@link@see와 비슷한데, 다른 태그 안에서도 쓸 수 있다는 점이 달라요.

/** @returns A {@link Box} containing the parameter. */
function box<U>(u: U): Box<U> {
  return { t: u };
}

프로퍼티도 링크할 수 있어요.

/**
 * Note: you should implement the {@link Pet.hello} method of Pet.
 */
function hello(p: Pet) {
  p.hello()
}

선택 이름(optional name)을 붙일 수도 있어요.

/**
 * Note: you should implement the {@link Pet.hello | hello} method of Pet.
 */
function hello(p: Pet) {
  p.hello()
}

Other

@enum

@enum 태그는 모든 멤버가 지정된 타입인 객체 리터럴을 만들 수 있게 해 줘요. JavaScript의 대부분의 객체 리터럴과 달리, 다른 멤버는 허용하지 않아요. @enum은 Google Closure의 @enum 태그와 호환되도록 만들어진 거예요.

/** @enum {number} */
const JSDocState = {
  BeginningOfLine: 0,
  SawAsterisk: 1,
  SavingComments: 2,
};

JSDocState.SawAsterisk;

@enum은 TypeScript의 enum과는 꽤 다르고 훨씬 단순해요. 그리고 TypeScript enum과 달리 @enum어떤 타입이든 가질 수 있어요.

/** @enum {function(number): number} */
const MathFuncs = {
  add1: (n) => n + 1,
  id: (n) => -n,
  sub1: (n) => n - 1,
};

MathFuncs.add1;

@author

@author로 아이템의 작성자를 지정할 수 있어요.

/**
 * Welcome to awesome.ts
 * @author Ian Awesome <[email protected]>
 */

이메일 주소는 반드시 꺾쇠 괄호(< >)로 감싸는 걸 잊지 마세요. 그렇지 않으면 @example이 새 태그로 파싱되어 버립니다.

Other supported patterns

var someObj = {
  /**
   * @param {string} param1 - JSDocs on property assignments work
   */
  x: function (param1) {},
};

/**
 * As do jsdocs on variable assignments
 * @return {Window}
 */
let someFunc = function () {};

/**
 * And class methods
 * @param {string} greeting The greeting to use
 */
Foo.prototype.sayHi = (greeting) => console.log("Hi!");

/**
 * And arrow function expressions
 * @param {number} x - A multiplier
 */
let myArrow = (x) => x * x;

/**
 * Which means it works for function components in JSX too
 * @param {{a: string, b: number}} props - Some param
 */
var fc = (props) => <div>{props.a.charAt(0)}</div>;

/**
 * A parameter can be a class constructor, using Google Closure syntax.
 *
 * @param {{new(...args: any[]): object}} C - The class to register
 */
function registerClass(C) {}

/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn10(p1) {}

/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn9(p1) {
  return p1.join();
}

Unsupported patterns

객체 리터럴 타입 안의 프로퍼티 타입에 **후치 등호(postfix equals)**를 붙여도 선택적 프로퍼티를 지정하지 못해요.

/**
 * @type {{ a: string, b: number= }}
 */
var wrong;

대신 이렇게 프로퍼티 이름 뒤에 물음표를 붙여 주세요.

/**
 * Use postfix question on the property name instead:
 * @type {{ a: string, b?: number }}
 */
var right;

널러블(nullable) 타입은 strictNullChecks가 켜져 있을 때만 의미가 있어요.

/**
 * @type {?number}
 * With strictNullChecks: true  -- number | null
 * With strictNullChecks: false -- number
 */
var nullable;

TypeScript 고유 문법은 유니언 타입이에요.

/**
 * @type {number | null}
 * With strictNullChecks: true  -- number | null
 * With strictNullChecks: false -- number
 */
var unionNullable;

널러블이 아닌(non-nullable) 타입은 아무 의미가 없고, 그냥 원래 타입으로 취급돼요.

/**
 * @type {!number}
 * Just has type number
 */
var normal;

JSDoc의 타입 시스템과 달리, TypeScript는 타입이 null을 포함하는지 아닌지만 표시할 수 있어요. 명시적인 "non-nullable"이라는 개념은 없어요. strictNullChecks가 켜져 있으면 number는 널러블이 아니고, 꺼져 있으면 number는 널러블이에요.

Unsupported tags

TypeScript는 지원되지 않는 JSDoc 태그를 그냥 무시해요. 다음 태그들은 지원을 요청하는 열린 이슈가 있는 태그들이에요.

Legacy type synonyms

오래된 JavaScript 코드와 호환되도록, 흔한 타입 몇 가지에 별칭(alias)이 지정되어 있어요. 별칭 중 일부는 기존 타입과 같은 것들인데, 대부분은 거의 쓰이지 않아요. 예를 들어 Stringstring의 별칭으로 취급돼요. String이 TypeScript에서 실제 타입이긴 하지만, 옛 JSDoc에서는 string을 뜻할 때 흔히 쓰곤 했어요. 게다가 TypeScript에서 **첫 글자를 대문자로 쓴 원시 타입은 래퍼 타입(wrapper type)**이라, 거의 항상 실수로 쓰인 거예요. 그래서 컴파일러는 옛 JSDoc에서의 사용 방식에 따라 이 타입들을 동의어(synonym)로 취급해요.

  • String -> string
  • Number -> number
  • Boolean -> boolean
  • Void -> void
  • Undefined -> undefined
  • Null -> null
  • function -> Function
  • array -> Array<any>
  • promise -> Promise<any>
  • Object -> any
  • object -> any

마지막 네 개의 별칭은 noImplicitAny: true일 때 꺼져요.

  • objectObject는 내장 타입인데, Object는 거의 쓰이지 않아요.
  • arraypromise는 내장 타입이 아니지만, 프로그램 어딘가에 선언되어 있을 수도 있어요.

더 알아보기