ts-defaults
v1.0.1
Published
Defaults implementation with type-safety and mostly dynamic type adaptation.
Downloads
5
Maintainers
Readme
ts-defaults
Defaults implementation with type-safety and (mostly) dynamic type adaptation.
Requirements
- If utilizing TypeScript typings: TypeScript >= 3.5
Installation
npm i ts-defaults
Motivation
When a function takes an options object input parameter there is typically a default configuration somewhere. How do we type this properly?
Pitfalls of spread-based defaults
In a lot of cases, it is sufficient to use the spread/assign approach to assigning defaults:
export interface FuncOpts {
p1: string;
p2?: number;
p3?: string;
}
export const defaultFuncOpts: Partial<FuncOpts> = {
p2: 1,
};
export const func = (input: string, opts: FuncOpts) => {
const newOpts = { ...defaultFuncOpts, ...opts };
// ... function code
};
It's simple, but presents a problem:
newOpts
after the spread assign is typed as Partial<FuncOpts> & FuncOpts
which means p2
will have the type signature p2?: number | undefined
.
This is correct because nothing here on Partial<FuncOpts>
or FuncOpts
says a
is absolutely not undefined
. It is also entirely valid to for the user of the function to pass { p2: undefined }
as well. In that case p2
will still be undefined
after spreading defaults.
Even if there was another type added to that intersection that said p2
was never undefined
, type intersections do not narrow types. A discriminator is needed to narrow the type to specific sub type.
So...
How does the developer of the function assert a default value and have the typing system respect it without hand jamming a bunch of types while also not burdening the user to always specify p2
? It presents a typing challenge:
Where does the type
type T1 = { p1: string; p2: number; p3?: string };
come from after applying defaults so the developer does not run into TS2532: Object is possibly undefined
in strict mode or need to use non-null assertions everywhere?
See tests of group Basic spread approach
for examples of spread typing pitfalls.
Pitfalls of destructured defaults
One alternative solution to spread-based defaults is to use destructuring defaults. In a lot of cases this is sufficient but it doesn't help when you need to pass the original object around instead of the individual properties.
export interface FuncOpts {
p1: string;
p2?: number;
p3?: string;
}
export const func = (input: string, opts: FuncOpts) => {
const { p2 = 1 } = opts;
// ...
const value = compute(opts); // Oops!, p2 is undefined
};
It is also extremely easy to forget destructuring defaults don't apply when the value is null
! See here.
Usage
To help reduce the number of explicit types, use tsDefaults
:
Basic
- same effect as spread-based approach
- only allows optional properties in the default object
import tsDefaults from "ts-defaults";
export interface FuncOpts {
p1: string;
p2?: number;
p3?: string;
}
export const func = (input: string, opts: FuncOpts) => {
const newOpts = tsDefaults(opts, {
// p1: "b" --> ERROR: p1 is not a valid key here as it's required by the type of opts.
p2: 2,
});
// ...
};
Analysis
| opts | newOpts | typeof newOpts | typeof newOpts accurate? |
| :--------------------------- | :--------------------------- | :----------------------------------------- | :--------------------------------------------------------------------------------------- |
| { p1: "a" }
| { p1: "a", p2: 2 }
| { p1: string, p2?: number, p3?: string }
| ⚠️ - based on this usage, newOpts.p2
will always be a number |
| { p1: "a", p2: undefined }
| { p1: "a", p2: undefined }
| { p1: string, p2?: number, p3?: string }
| ❓ - undefined
is a valid value, but do we really want it over a default? Debatable... |
| { p1: "a", p2: 3 }
| { p1: "a", p2: 3 }
| { p1: string, p2?: number, p3?: string }
| ⚠️ - based on this usage, newOpts.p2
will always be a number |
| { p1: "a" }
| { p1: "a", p2: 2 }
| { p1: string, p2?: number, p3?: string }
| ⚠️ - based on this usage, newOpts.p2
will always be a number |
Enforced Defaults
- same effect as spread-based approach
- only allows optional properties in the default object
- Pass an array of optional keys of the first parameter (
opts
here) to enforce their defaults.- Requires that the key is specified in the defaults object
- Morphs the return type appropriately
import tsDefaults from "ts-defaults";
export interface FuncOpts {
p1: string;
p2?: number;
p3?: string;
}
export const func = (input: string, opts: FuncOpts) => {
const newOpts = tsDefaults(
opts,
{
// p1: "b" --> ERROR: p1 is not a valid key here as it's required by the type of opts.
p2: 2,
p3: "ABC",
},
["p2"]
);
// ...
};
Analysis
| opts | newOpts | typeof newOpts | typeof newOpts accurate? |
| :--------------------------- | :---------------------------------- | :---------------------------------------- | :----------------------- |
| { p1: "a" }
| { p1: "a", p2: 2, p3: "ABC" }
| { p1: string, p2: number, p3?: string }
| ✅ |
| { p1: "a", p2: 3 }
| { p1: "a", p2: 3, p3: "ABC" }
| { p1: string, p2: number, p3?: string }
| ✅ |
| { p1: "a", p2: undefined }
| { p1: "a", p2: 2, p3: "ABC" }
| { p1: string, p2: number, p3?: string }
| ✅ |
| { p1: "a", p3: "z" }
| { p1: "a", p2: 2, p3: "z" }
| { p1: string, p2: number, p3?: string }
| ✅ |
| { p1: "a", p3: undefined }
| { p1: "a", p2: 2, p3: undefined }
| { p1: string, p2: number, p3?: string }
| ✅ |
Notice that in each case, p2
is p2: number
and we didn't need any helper types 🥳
You might be asking why is p3
still p3?: string
when we have a default?
Due to the possibility of wanting to mix enforced defaults with standard spread behavior, the third parameter is required to appropriately morph the type. Simply add p3
to the array and it will be enforced and its type morphed.
Defaults as an exportable variable
Many times it is best to make default options exportable. Piggybacking off the "Enforced Defaults" example, one recommended way to handle this:
import { DeepReadonly } from "ts-essentials";
import tsDefaults, { ObjectDefaults } from "ts-defaults";
export interface FuncOpts {
p1: string;
p2?: number;
p3?: string;
}
export const defaultFuncOpts: DeepReadonly<ObjectDefaults<FuncOpts, "p2">> = {
// "p2" | "p3" to enforce p3 as well, p1 is NOT valid here as it's not an optional key
// p1: "b" --> ERROR: p1 is not a valid key.
p2: 2,
p3: "ABC",
};
export const func = (input: string, opts: FuncOpts) => {
const newOpts = tsDefaults(opts, defaultFuncOpts, ["p2"]);
// ...
};
DeepReadonly from ts-essentials is recommended if you have inner objects on your options type.
If your options type is simply a dictionary of primitives, the built-in Readonly should suffice (ex: Readonly<ObjectDefaults<FuncOpts, "p2">>
).
What happens if I need to cast the parameters?
Don't use the function's generic parameters (ex. tsDefaults<MyInterface>(opts, ...)
). Use as
casts instead (ex. tsDefaults(opts as MyInterface, ...)
).
What is the explict return type of tsDefaults
in the case I need it earlier?
Example:
WithEnforcedDefaults<MyOptsType, "union" | "of" | "enforced" | "keys">
License
MIT