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
...
}
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.
}
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 }
}
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 }
}
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 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
}
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) }
}
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 {}
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
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>
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 }
}
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 {}
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
Top comments (0)