DEV Community

Luke Harold Miles
Luke Harold Miles

Posted on • Updated on

Documenting default interface values in typescript, or trying to...

Update May 2022: I made an npm package for default interfaces in typescript.


You have a function that takes options with default values. How can you document the default values? You want the types & defaults to survive in your generated .d.ts files.

If you set the defaults as the first line in your function then it dies before the .d.ts:

function addUser(options: {id: string, isAdmin?: boolean, color?: string}) {
    const { id, isAdmin = false, color="red" } = options
    // PROBLEM: Above line not visible in documentation or type files
    // Someone using this library has no way to determine the default color
    ...
}
Enter fullscreen mode Exit fullscreen mode

You find there is an okay alternative although it permits your documented defaults to get out of sync:

type Default<T, S> = T | undefined

/** Default is shown in docs and declarations */
function addUser(options: {
    id: string
    isAdmin: Default<bolean, false>
    color: Default<string, 'red'>
}) {
    const { id, isAdmin = false, color = 'red' } = options
    // Changing one 'red' but not the other results in no type errors.
    // Would be better if error was shown.
}
Enter fullscreen mode Exit fullscreen mode

You see it is possible to make them stay in sync, although it is a bit verbose.

const defaults = {
    isAdmin: false,
    color: 'red',
} as const

type MoreOptions = {
    color: string
    index: number
}

type DefaultOf<A, B> = Partial<A>

// defaults stay correct and shown in declaration/docs
function addUser(
    id: string,
    options: {
        isAdmin: boolean
    } & DefaultOf<MoreOptions, typeof defaults>
) {
    const { isAdmin, color, index } = { ...defaults, ...options }
}
Enter fullscreen mode Exit fullscreen mode

You make another attempt but it was worse

/** Where are you going with this */

type Default<T, S> = T | undefined

const isAdminDefault = false as const
const colorDefault = 'red' as const
/** disaster */
function addUser(options: {
    id: string
    isAdmin: Default<boolean, typeof isAdminDefault>
    color: Default<boolean, typeof colorDefault>
}) {
    const {
        id,
        isAdmin = isAdminDefault,
        color = colorDefault,
    } = { ...options }
}
Enter fullscreen mode Exit fullscreen mode

You recall how simple the first example was. You start to wonder if any of this is worth the trouble. You find a closed issue on a popular library where the maintainers said it was a massive pain in the ass to get default values into the declarations. The jsdoc @default thing makes you wonder.

Searching further for some way to "automatically" document your default values you see issues with years passed between problems and partial solutions, followed by sudden closure response. It seems this may be impossible.

You find another popular library with a function in their typedoc that seems to have default values shown without any special effort in the source. Sure, those are positional arguments, but still, there must be some way.

You remember the second attempt wasn't too bad either, just a simple Default<string, 'red'>. Easy to read; easy to write. But what if you change that red to blue in one place but not another? Your documentation would be wrong. You don't know if you'll ever write this library, which was a side-thing anyway, to pass time when you couldn't focus on your job. But if you do write this library the docs are going to be correct as shit. So correct.


You give it the old npm i -g typedoc and run it on your original 3-line index.ts test file. Apparently it generates a static asset directory so you cd in and python3 -m http.server and peak at localhost 8000.

Nope

Nope typedoc just isn't quite there. Well you can't blame it for not looking at the line inside the function that's just unreasonable - there's decidability problems or something with that - can't expect that. You give it a helping hand:

function addUser({
    id,
    isAdmin = false,
    color = 'red',
}: {
    id: string
    isAdmin?: boolean
    color?: string
}) {
    // please typedoc please
}
Enter fullscreen mode Exit fullscreen mode

Nope 2

It seems you upset typedoc because now the docs are even worse. "There must be a way, there must be a way" you think to yourself.

Perhaps typedoc handles classes well. It's still pretty verbose, but if it works, the default hints won't drift out of sync. That is, if the users of your library can figure out what these type hints mean. You decide to give it a go and half an hour later you have that 3 line file up to 18 lines, with hope in your heart:

/** You have lost your way my friend */

// You tried to leave this interface in the constructor
//  but you entirely failed to retrieve Parameters<> of a class constructor
//  because it doesn't satisfy normal function constraints.
// You also tried to derive the mandatory and optional fields
//  using mapped index types and various other tricks but you
//  simply failed again.
interface PartialOptions {
    id: string
    excellence: number
    color?: string
    isAdmin?: boolean
}

class Options {
    isAdmin = false
    color = 'red'
    // you tried to avoid the ! but typescript doesn't acknowledge your crummy Object.assign
    id!: string
    excellence!: number
    constructor(o: PartialOptions) {
        Object.assign(this, o)
    }
}

/** You tried putting options: Options here but
 *    of course then isAdmin and color would be required */
export function addUser(options: PartialOptions) {
    const { isAdmin, color, id, excellence } = { ...new Options(options) }
}
Enter fullscreen mode Exit fullscreen mode

As awful as this is, something about it gives you confidence. Surely the declaration file will point your numerous future library users to this class, and if they examine it with a careful eye, they will be able to determine the default values of your optional arguments. You hold your breath, run tsc, and open index.d.ts

interface PartialOptions {
    id: string
    excellence: number
    color?: string
    isAdmin?: boolean
}
export declare function addUser(options: PartialOptions): void
export {}
Enter fullscreen mode Exit fullscreen mode

THE DEFAULTS ARE GONE

Of course they are. The class is not exported. Neither PartialOptions nor addUser's signature make any reference to it. So why would the class be included? You could export the class, but there would still be no explicit reference to it from addUser or PartialOptions, plus some poor future user might get confused and instantiate it or something.

You make a smoothie with berries and banana to try to get your mind off of all of this but when you return to your "work" computer you find yourself compulsively digging deeper. There has to be a way. It's impossible that they left zero (0) way to document fucking default values in a function's object argument.

Well there was the Default<string, 'red'> thing and the @default typedoc command but you remind yourself that both of those permit incorrect documentation. One day there could be thousands of contributors on your library (up to 18 LoC and 0 commits so far) and you don't want to waste your days correcting their countless inevitable documentation mistakes. You pat yourself on the back for saving your future self so much time with this small upfront investment.

It occurs to you the most important documentation of a function is in fact its name and there is nothing anywhere in your compilation system that verifies that functions are named correctly. "How hard would it be?" Something named calcX or getX should return type X. Something named setX should take X. Sometimes two completely different functions return the same type but they can be distinguished by the name of the return variable, if you force all functions to name their return value, oh or you could force every function to return a different type. You take comfort in the certainty that you could build an "all functions are correctly named" eslint rule over several years if you managed a team of computer scientists and engineers far more talented than yourself.


Well anyway it is fine if the functions are badly named. That is none of your concern. What you are concerned with here is having correctly documented default values in function arguments.

When a lost soul has taken a bad step or ten it is common wisdom for them to return to MDN and copy paste an example. Perhaps their example, although in javascript and having no authorial intent to be used for generating typescript declaration files, is so perfect that tsc will submit to it.

// index.ts:

export function f({ z = 3 } = {}) {
    return z
}

// index.d.ts:
export declare function f({ z }?: { z?: number | undefined }): number
// ^ you see no mention of the number 3 (three)
// you try typedoc but it's also junk
Enter fullscreen mode Exit fullscreen mode

Of course that wouldn't work! It's no different from what you've tried before. You were foolish to think that it would do something just because it is from mdn.

"Right now positional arguments don't sound so bad."

No! That's devil's speak! Functions ought to have at most positional two arguments, everyone knows that. Otherwise your users will get confused.

Recalling your 3rd attempt, it occurs to you that the only reason you needed both defaults and MoreOptions was that you had no simple way to elevate the literal types 'red' and false up to their more general types string and boolean. Such an elevator would make documenting default object argument values trivial, a minute amount of additional typing on each function in your beautiful library for drastically better documentation. You give it a shot.

// all hidden within your library in a file, definitely nothing for users to worry about
type Elevator<T> = T extends string
    ? string
    : T extends number
    ? number
    : T extends boolean
    ? boolean
    : T extends Array<infer X>
    ? X[]
    : T

const x = 5 as const
type XEl = Elevator<typeof x>
// number! success

type ElevateObj<Obj> = {
    [K in keyof Obj]: Elevator<Obj[K]>
}
type DefaultOf<A, B> = Partial<A>
Enter fullscreen mode Exit fullscreen mode

Looking good, now just for the addUser...

// clean library source file, using that convenient type helper file:

const defaults = {
    isAdmin: false,
    color: 'red',
} as const
type MoreOptions = ElevateObj<typeof defaults>

export function addUser(
    options: {
        id: string
        excellence: boolean
    } & DefaultOf<MoreOptions, typeof defaults>
) {
    const { id, isAdmin, color } = { ...defaults, ...options }
}
Enter fullscreen mode Exit fullscreen mode

You might have just done it. Do you finally have it? You try the tsc again

// index.d.ts
declare type Elevator<T> = T extends string
    ? string
    : T extends number
    ? number
    : T extends boolean
    ? boolean
    : T extends Array<infer X>
    ? X[]
    : T
declare type ElevateObj<Obj> = {
    [K in keyof Obj]: Elevator<Obj[K]>
}
declare type DefaultOf<A, B> = Partial<A>
declare const defaults: {
    readonly isAdmin: false
    readonly color: 'red'
}
declare type MoreOptions = ElevateObj<typeof defaults>
export declare function addUser(
    options: {
        id: string
        excellence: boolean
    } & DefaultOf<MoreOptions, typeof defaults>
): void
export {}
Enter fullscreen mode Exit fullscreen mode

Clean\
as\
a whistle

Well if they ctrl-click on a addUser and hover on the word defaults exactly then they can see the defaults. If that's not a victory I don't know what is.

Damn they don't use the term 10x engineer for nothin.

Relieved that you've cracked the case, you type out a deep breath. You can finally develop that library with some peace of mind.

cd $HOME/projects ; sudo rm -rf * ; shutdown
Enter fullscreen mode Exit fullscreen mode

Top comments (0)