TypeScript's function overloads let you describe an API whose return type and arguments change depending on how it is called. The pattern applies to a simple callback-or-promise helper just as well as to something far stranger, such as a compile-time code generator.

The basic case: callback or promise

Suppose you need a function that accepts a callback, but returns a promise when no callback is given.

const logResult = (result) => console.log(`result: ${result}`)
asyncAdd(1, 2).then(logResult) // logs "result: 3"
asyncAdd(3, 6, logResult) // logs "result: 9"

The plain JavaScript implementation looks like this:

function asyncAdd(a, b, cb) {
	const result = a + b
	if (cb) return cb(result)
	else return Promise.resolve(result)
}

What we want is for TypeScript to flag the bad usage below:

// @ts-expect-error because when the cb is provided, void is returned so you can't use ".then"!
asyncAdd(1, 2, logResult).then(logResult) // this would throw an error when trying to use ".then" (except we're using TypeScript so it won't even compile 😉)

To get there, declare each acceptable call shape as its own signature:

type asyncAddCb = (result: number) => void
// define all valid function signatures
function asyncAdd(a: number, b: number): Promise<number>
function asyncAdd(a: number, b: number, cb: asyncAddCb): void

// define the actual implementation
// notice cb is optional
// also notice that the return type is inferred, but it could be specified as `void | Promise<number>`
function asyncAdd(a: number, b: number, cb?: asyncAddCb) {
	const result = a + b
	if (cb) return cb(result)
	else return Promise.resolve(result)
}

A more involved example: compile-time code generation

The more interesting motivation comes from babel-plugin-codegen, a package that generates code at compile time. Given a file containing code such as:

// @codegen
const fs = require('fs')
const fruits = fs.readFileSync('./fruit.txt', 'utf8').toString().split('\n')
module.exports = fruits
	.map((fruit) => `export const ${fruit} = '${fruit}';`)
	.join('')

and assuming fruit.txt holds a list of fruits, the compiled result is:

export const apple = 'apple'
export const orange = 'orange'
export const pear = 'pear'

The plugin emits a string of code, which codegen converts into real code in your output.

The plugin also works alongside babel-plugin-macros, which provides importable babel transforms. Rather than configuring babel-plugin-codegen directly, you configure babel-plugin-macros, install codegen.macro, and import it:

import codegen from 'codegen.macro'

// using as a tagged template literal:
codegen`
  module.exports = "const tag = 'this is an example'"
`

// using as a function
codegen(`
  module.exports = "const fn = 'this is another example'"
`)

// codegen-ing an external module (and pass an argument):
const jpgs = codegen.require('./get-files-list', '**/*.jpg')

const ui = <Codegen>{`module.exports = require('./some-jsx-code')`}</Codegen>

That can compile to something like:

// using as a tagged template literal:
const tag = 'this is an example'

// using as a function
const fn = 'this is another example'

// codegen-ing an external module (and pass an argument):
const jpgs = ['kody.jpg', 'olivia.jpg', 'marty.jpg']

const ui = <div>This is some example JSX code</div>

Typing a macro as overloads

The real codegen implementation is a babel macro, so it does not resemble the API above at all. It runs during compilation and receives ASTs as arguments. What matters, though, is the consumer's experience, so the goal is a set of types describing the function as it is expected to be used, with the macro then cast to that shape.

import { createMacro } from 'babel-plugin-macros'
import type { MacroHandler } from 'babel-plugin-macros'

const codegenMacro: MacroHandler = function codegenMacro(/* some args */) {
	// the implementation here is irrelevant
}

// use the `createMacro` utility to turn the codegenMacro into a babel macro
const macro = createMacro(codegenMacro)

Note that user code never actually calls the macro function. babel-plugin-macros calls it with the MacroHandler arguments. But from TypeScript's point of view the developer is the caller, so the type definitions have to reflect that:

// This handles the tagged template literal API:
declare function codegen(
	literals: TemplateStringsArray,
	...interpolations: Array<unknown>
): any

// this handles the function call API:
declare function codegen(code: string): any

// this handles the `codegen.require` API:
declare namespace codegen {
	function require(modulePath: string, ...args: Array<unknown>): any
}

// Unfortunately I couldn't figure out how to add TS support for the JSX form
// Something about the overload not being supported because codegen can't be all the things or whatever
// PRs welcome!

With the overloads in place, the remaining step is to make TypeScript treat the macro file as the codegen function defined above, and to expose it as the macro file's default export:

export default macro as typeof codegen

The complete version lives in the babel-plugin-codegen src/macro.ts file.