Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: derive constructor options from chainable Base.defaults() calls #57

Merged
merged 19 commits into from
Jul 17, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions debug-base-with-defaults/index-gr2m.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
type Optional<T extends object, K extends string | number | symbol> = Omit<
T,
K
> &
Partial<Pick<T, keyof T & K>>;

interface Options {
version: string;
}

type Constructor<T> = new (...args: any[]) => T;

class Base<
TDefaults extends Partial<Options>,
TOptions extends Optional<Options, keyof TDefaults>
> {
static defaults<
TDefaultsOptions extends Partial<Options> & Record<string, unknown>,
TOptionalOptions extends Optional<Options, keyof TDefaultsOptions>,
S extends Constructor<Base<TDefaultsOptions, TOptionalOptions>>
>(
this: S,
defaults: TDefaultsOptions
): {
new (...args: any[]): {
options: TOptionalOptions;
};
} & S {
return class extends this {
constructor(...args: any[]) {
super(Object.assign({}, defaults, args[0] || {}));
}
};
}

constructor(options: TOptions & Record<string, unknown>) {
this.options = options;
}

options: TOptions;
}

const test = new Base({
// `version` should be typed as required for the `Base` constructor
version: "1.2.3",
});
const MyBaseWithDefaults = Base.defaults({
// `version` should be typed as optional for `.defaults()`
customDefault: "",
});
const MyBaseWithVersion = Base.defaults({
version: "1.2.3",
customDefault: "",
});
const testWithDefaults = new MyBaseWithVersion({
// `version` should not be required to be set at all
customOption: "",
});

// should be both typed as string
testWithDefaults.options.version;
testWithDefaults.options.customDefault;
testWithDefaults.options.customOption;
57 changes: 57 additions & 0 deletions debug-base-with-defaults/index-parzh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
type WithOptional<
OriginalObject extends object,
OptionalKey extends keyof OriginalObject = never
> = Omit<OriginalObject, OptionalKey> &
Partial<Pick<OriginalObject, OptionalKey>>;

type KeyOfByValue<Obj extends object, Value> = {
[Key in keyof Obj]: Obj[Key] extends Value ? Key : never;
}[keyof Obj];

type RequiredKey<Obj extends object> = Exclude<
KeyOfByValue<Obj, Exclude<Obj[keyof Obj], undefined>>,
undefined
>;

type OptionalParamIfEmpty<Obj extends object> = RequiredKey<Obj> extends never
? [Obj?]
: [Obj];

interface Constructor<Params extends object, Instance extends object = object> {
new (...args: OptionalParamIfEmpty<Params>): Instance;
}

interface Options {
foo: string;
bar: number;
baz: boolean;
}

class Base {
static defaults<OptionalKey extends keyof Options>(
defaults: { [Key in OptionalKey]: Options[Key] }
) {
return class BaseWithDefaults extends Base {
constructor(
...[partialParams]: OptionalParamIfEmpty<
WithOptional<Options, OptionalKey>
>
) {
super({ ...defaults, ...partialParams } as Options);
}
};
}

public options: Options;

constructor(options: Options) {
this.options = options;
}
}

const MyBase = Base.defaults({ foo: "bar" });
const OhMyBase = MyBase.defaults({ bar: 1 });
const base = new OhMyBase({
baz: true,
foo: "baz",
});
83 changes: 83 additions & 0 deletions debug-base-with-defaults/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// <utils>
// by Dima Parzhitsky https://github.com/parzh (https://stackoverflow.com/a/68254847/206879)
type WithOptionalKeys<
OriginalObject extends object,
OptionalKey extends keyof OriginalObject = never
> = Omit<OriginalObject, OptionalKey> &
Partial<Pick<OriginalObject, OptionalKey>>;

type KeyOfByValue<Obj extends object, Value> = {
[Key in keyof Obj]: Obj[Key] extends Value ? Key : never;
}[keyof Obj];

type RequiredKeys<Obj extends object> = Exclude<
KeyOfByValue<Obj, Exclude<Obj[keyof Obj], undefined>>,
undefined
>;

type OptionalParamIfEmpty<Obj extends object> = RequiredKeys<Obj> extends never
? [Obj?]
: [Obj];
// </utils>

interface Options {
version: string;
customDefault?: string;
customOption?: string;
}

interface Constructor<Params extends object, Instance extends object = object> {
new (...args: any[]): Instance;
// new (...args: OptionalParamIfEmpty<Params>): Instance;
}

class Base {
static defaults<
OptionalKey extends keyof Options,
S extends Constructor<WithOptionalKeys<Options, OptionalKey>, Base>
>(this: S, defaults: { [Key in OptionalKey]: Options[Key] }): S {
return class extends this {
constructor(
...[partialParams]: OptionalParamIfEmpty<
WithOptionalKeys<Options, OptionalKey>
>
);

constructor(...args: any[]) {
super({ ...defaults, ...args[0] } as Options);
}
};
}

public options: Options;

constructor(options: Options) {
this.options = options;
}
}

const test = new Base({
// `version` should be typed as required for the `Base` constructor
version: "1.2.3",
});
const MyBaseWithDefaults = Base.defaults({
// `version` should be typed as optional for `.defaults()`
customDefault: "",
});
const MyBaseWithVersion = Base.defaults({
version: "1.2.3",
customDefault: "",
});
const testWithDefaults = new MyBaseWithVersion({
// `version` should not be required to be set at all
customOption: "",
});

// should be both typed as string
testWithDefaults.options.version;
testWithDefaults.options.customDefault;
testWithDefaults.options.customOption;

const MyCascadedBase = Base.defaults({ customDefault: "1" }).defaults({
version: "1.2.3",
});
27 changes: 27 additions & 0 deletions debug-defaults-and-options/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
type Optional<T extends object, K extends string | number | symbol> = Omit<
T,
K
> &
Partial<Pick<T, keyof T & K>>;

type Options = {
requiredOption: string;
optionalOption?: string;
};

/**
* If the `defaults` parameter includes all required options, the `options`
* parameter is not required
*/
export function testWithDefaults<
TDefaults extends Options & Record<string, unknown>
>(defaults: TDefaults): TDefaults;

/**
* All properties set in `defaults` do not need to be defined in `options`.
* Together they must set all required options
*/
export function testWithDefaults<
TDefaults extends Partial<Options>,
TOptions extends Optional<Options, keyof TDefaults> & Record<string, unknown>
>(defaults: TDefaults, options: TOptions): TDefaults & TOptions;
6 changes: 6 additions & 0 deletions debug-defaults-and-options/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function testWithDefaults(defaults, options) {
return {
...defaults,
...options,
};
}
72 changes: 72 additions & 0 deletions debug-defaults-and-options/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { expectType } from "tsd";
import { testWithDefaults } from "./index.js";

expectType<{ requiredOption: string }>(
testWithDefaults(
{
requiredOption: "",
},
{}
)
);
expectType<{ requiredOption: string }>(
testWithDefaults({
requiredOption: "",
})
);
expectType<{ requiredOption: "" }>(
testWithDefaults(
{},
{
requiredOption: "",
}
)
);
expectType<{ requiredOption: string; default: string }>(
testWithDefaults(
{
default: "",
requiredOption: "",
},
{}
)
);
expectType<{ requiredOption: string } & { option: string }>(
testWithDefaults(
{
requiredOption: "",
},
{
option: "",
}
)
);
expectType<{ requiredOption: string }>(
testWithDefaults(
{
requiredOption: "",
},
{}
)
);
expectType<{ requiredOption: string } & { optionalOption: ""; option: string }>(
testWithDefaults(
{
requiredOption: "",
},
{
optionalOption: "",
option: "",
}
)
);

testWithDefaults(
{},
// @ts-expect-error - requiredOption is not defined in the options
{}
);
testWithDefaults(
// @ts-expect-error - requiredOption is not defined in the defaults, so options is required
{}
);
Loading