npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

ts-defaults

v1.0.1

Published

Defaults implementation with type-safety and mostly dynamic type adaptation.

Downloads

5

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