Recipe · Functions

How do I type a function that also has properties?

  • Node.js (ES modules)
  • Function properties
  • @typedef
  • @type
  • Checked with TypeScript 5.9

The problem My function also carries a few fields, like formatPrice.symbol and a call counter. How do I get tsc to know about those properties without turning the function into a class?

The short answer

Assign the properties right after the function, at the top level of the same file. tsc adds them to the function’s type:

format.js format.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @param {number} cents
 * @returns {string}
 */
export function formatPrice(cents) {
  formatPrice.calls += 1;
  return `${formatPrice.symbol}${(cents / 100).toFixed(2)}`;
}
formatPrice.symbol = "$";
formatPrice.calls = 0;

formatPrice.symbol = "€";
console.log(formatPrice(1999), formatPrice.calls);
export function formatPrice(cents: number): string {
  formatPrice.calls += 1;
  return `${formatPrice.symbol}${(cents / 100).toFixed(2)}`;
}
formatPrice.symbol = "$";
formatPrice.calls = 0;

formatPrice.symbol = "€";
console.log(formatPrice(1999), formatPrice.calls);
A function with properties assigned after it.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

formatPrice.symbol is a string and formatPrice.calls a number, both inside the function and wherever formatPrice is imported.

Why it works

Since TypeScript 3.1, assignments like formatPrice.symbol = "$" right after a function declaration (or a const holding a function) declare properties on it. In a .js file this is part of how tsc reads JavaScript: the type of formatPrice becomes “a function from number to string, with a symbol and a calls property”.

When the function is made somewhere else, such as by a factory, there are no assignments to read. Then describe the whole shape as a type: a function type intersected with an object type, built with Object.assign so the value has both parts:

format.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @typedef {object} PriceFormatter
 * @property {string} symbol
 * @property {number} calls
 */

/** @typedef {((cents: number) => string) & PriceFormatter} Formatter */

/**
 * @param {string} symbol
 * @returns {Formatter}
 */
export function makeFormatter(symbol) {
  const format = Object.assign((/** @type {number} */ cents) => `${format.symbol}${(cents / 100).toFixed(2)}`, { symbol, calls: 0 });
  return format;
}

const euros = makeFormatter("€");
console.log(euros(1999), euros.symbol);
The same shape as a named type with a call signature.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

The same thing in TypeScript

The TypeScript equivalent tab of the first example is the same code: TypeScript reads property assignments on functions in .ts files too. The factory’s type in TypeScript would be ((cents: number) => string) & { symbol: string; calls: number }, the same type the @typedef writes.

What goes wrong

The type of an assigned property is the union of the types assigned to it in that file. A second assignment of a number turns symbol into string | number, so the string method inside the function fails. A property that is never assigned does not exist (TS2339):

format.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: format.js(7,32): error TS2339: Property 'trim' does not exist on type 'string | number'. Property 'trim' does not exist on type 'number'. format.js(13,25): error TS2339: Property 'currency' does not exist on type 'typeof formatPrice'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. format.js(7,32): error TS2339: Property 'trim' does not exist on type 'string | number'. Property 'trim' does not exist on type 'number'. format.js(13,25): error TS2339: Property 'currency' does not exist on type 'typeof formatPrice'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. format.js(7,32): error TS2339: Property 'trim' does not exist on type 'string | number'. Property 'trim' does not exist on type 'number'. format.js(13,25): error TS2339: Property 'currency' does not exist on type '{ (cents: number): string; symbol: string | number; }'. checked 21 October 2026
/**
 * @param {number} cents
 * @returns {string}
 */
export function formatPrice(cents) {
  return `${formatPrice.symbol.trim()}${(cents / 100).toFixed(2)}`;error TS2339: Property 'trim' does not exist on type 'string | number'.
  Property 'trim' does not exist on type 'number'.
}
formatPrice.symbol = "$";

formatPrice.symbol = 36;
console.log(formatPrice.currency);error TS2339: Property 'currency' does not exist on type 'typeof formatPrice'.
A second assignment widens the property; a property never assigned does not exist.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

To pin the type, put a @type tag on the first assignment. Then a later assignment of the wrong type is the error, where it belongs:

format.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: format.js(12,1): error TS2322: Type 'number' is not assignable to type 'string'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. format.js(12,1): error TS2322: Type 'number' is not assignable to type 'string'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. format.js(12,1): error TS2322: Type 'number' is not assignable to type 'string'. checked 21 October 2026
/**
 * @param {number} cents
 * @returns {string}
 */
export function formatPrice(cents) {
  return `${formatPrice.symbol}${(cents / 100).toFixed(2)}`;
}
/** @type {string} */
formatPrice.symbol = "$";

formatPrice.symbol = 36;error TS2322: Type 'number' is not assignable to type 'string'.
A @type on the first assignment fixes the property's type.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

Gotchas

  • Same file, top level. An assignment inside another function does not declare a property; tsc reports TS2339 for it.
  • Static class members are often clearer. If the function grows several properties and helpers, a class with static members says the same thing with less magic.
  • Importers can read the properties, not add them. A module that imports formatPrice sees symbol and calls, but formatPrice.locale = "en" there is TS2339: new properties belong in the defining file.

Questions

Does this work for arrow functions too?

Yes, for an arrow function assigned to a const in the same file: const formatPrice = (cents) => … followed by formatPrice.symbol = "$" adds the property the same way.

How do I type a function with properties that I receive as a parameter?

Describe it with an intersection of a function type and an object type, {((cents: number) => string) & { symbol: string }}, or an object type with a call signature, {{ (cents: number): string, symbol: string }}.

Sources

Last reviewed 21 October 2026.