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:
/**
* @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);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:
/**
* @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);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):
/**
* @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'.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:
/**
* @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'.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
formatPriceseessymbolandcalls, butformatPrice.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
- TypeScript 3.1 release notes (properties declarations on functions) · TypeScript handbook
- Type Checking JavaScript Files (properties are inferred from assignments) · TypeScript handbook
- More on Functions (call signatures) · TypeScript handbook
Last reviewed 21 October 2026.