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

Conversation

gr2m
Copy link
Owner

@gr2m gr2m commented Jun 18, 2021

Given that a version constructor parameter is required, the following code should work without type errors.

const Base123 = Base.defaults({ version: "1.2.3" });
const base = new Base123();

@gr2m gr2m force-pushed the defaults-options-inheritance branch from bf15c10 to 964c604 Compare June 30, 2021 19:48
@gr2m
Copy link
Owner Author

gr2m commented Jun 30, 2021

@gr2m
Copy link
Owner Author

gr2m commented Jul 1, 2021

progress (I think): I managed to create a function which accepts both defaults and options, and all the type behavior behaves as expected:

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;
};

function testWithDefaults<TDefaults extends Options>(defaults: TDefaults);
function testWithDefaults<
  TDefaults extends Partial<Options>,
  TOptions extends Optional<Options, keyof TDefaults> & Record<string, unknown>
>(defaults: TDefaults, options: TOptions);
function testWithDefaults(
  defaults: Record<string, unknown>,
  options?: Record<string, unknown>
) {
  return {
    ...defaults,
    ...options,
  };
}

const test = testWithDefaults({ requiredOption: ""}, {})

playground link

@gr2m
Copy link
Owner Author

gr2m commented Jul 14, 2021

I've spent way too much time on this already.

The answers to my the StackOverflow question do not work, because they both only implement a single Base.defaults() call and do not support chaining (Base.defaults().defaults()).

I've reached out to @JoshuaKGoldberg for more help and their assessment is that it's not possible with today's TypeScript features. Josh opened two issues on the TS repository

two other related issues are

I'd like to try one more approach: limit the amount of chaining to 3x and implement the carrying of state when chaining static class methods by duplicating & nesting code.

index.d.ts Show resolved Hide resolved
index.test-d.ts Show resolved Hide resolved
defaultOne: string,
defaultTwo: number,
version: string,
}>({ ...BaseLevelTwo.defaultOptions });
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the destructoring? Isn't this the same as

Suggested change
}>({ ...BaseLevelTwo.defaultOptions });
}>(BaseLevelTwo.defaultOptions);

Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg Jul 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha, this one was nifty. tsd sees an intersection type as not being the same as its equivalent solo type.

  index.test-d.ts:86:0
  ✖  56:0  Parameter type { defaultOne: string; defaultTwo: number; version: string; } is not identical to argument type { defaultOne: string; version: string; } & { defaultTwo: number; }.  

@gr2m
Copy link
Owner Author

gr2m commented Jul 15, 2021

I accidentally closed #59 by pushing it to the defaults-options-inheritance branch in this repository instead of @JoshuaKGoldberg's fork.

Josh wrote

Builds onto #57 with the long-lived temporary solution @gr2m referenced in that PR thread of having a known finite level of nesting.

I did away with fancy conditional types to make sure the type system doesn't get bogged down computing them all for plugins with a few options. I can add them back if you have test cases that require more than just version being required?

The big return type of the static defaults in theory could be automated a little bit with a recursive type. However, we wouldn't be able to go infinitely deep, and again the code to get that to work would be a good bit longer and less readable than how it is now. 🤷

I think what broke is the combination of .plugin().defaults() call now

Here is a complex test I'd like to see pass

const BaseWithManyChainedDefaultsAndPlugins = Base.defaults({
  defaultOne: "value",
})
  .plugin(fooPlugin, barPlugin, voidPlugin)
  .defaults({
    defaultTwo: 0,
  })
  .plugin(withOptionsPlugin)
  .defaults({
    defaultThree: ["a", "b", "c"],
  });

expectType<{
  defaultOne: string;
  defaultTwo: number;
  defaultThree: string[];
}>({ ...BaseWithManyChainedDefaultsAndPlugins.defaultOptions });

const baseWithManyChainedDefaultsAndPlugins =
  new BaseWithManyChainedDefaultsAndPlugins({
    version: "1.2.3",
    foo: "bar",
  });

expectType<string>(baseWithManyChainedDefaultsAndPlugins.foo);
expectType<string>(baseWithManyChainedDefaultsAndPlugins.bar);
expectType<string>(baseWithManyChainedDefaultsAndPlugins.getFooOption());

Right now the last three expectType calls checking the mixins from the plugins fail

A minimal test case to reproduce this problem is

const BaseWithManyChainedDefaultsAndPlugins = Base.defaults({
  defaultOne: "value",
})
  .plugin(fooPlugin)
  .defaults({
    defaultTwo: 0,
  });

const baseWithManyChainedDefaultsAndPlugins =
  new BaseWithManyChainedDefaultsAndPlugins({
    version: "1.2.3",
  });

expectType<string>(baseWithManyChainedDefaultsAndPlugins.foo);

Calling Base.defaults().plugin().defaults() breaks the mixins from the plugins. Both Base.defaults().plugin() and Base.plugin().defaults() works

JoshuaKGoldberg and others added 3 commits July 15, 2021 18:33
Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
index.d.ts Outdated Show resolved Hide resolved
T1 extends Plugin,
T2 extends Plugin[]
Class extends ClassWithPlugins,
Plugins extends [Plugin, ...Plugin[]],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New little nifty improvement recently added to tuples in TypeScript ✨ no more multiple parameters!

version: "1.2.3",
});

expectType<string>(baseWithChainedDefaultsAndPlugins.foo);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a tough one! It doesn't repro if either of the .defaults({ ... }) are removed above... Tricky!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! It was the this: Class generic.

My leading hunch was that whatever information added the foo: string from the .plugin(fooPlugin) call was being abandoned by the .defaults calls going on top of each other. That meant the piece of the type that contained foo wasn't getting preserved.

That piece of type was the this: Class -> & Class. Using just one <Class> generic from the start meant new type information on new this scopes that had extra stuff added to them was being lost.

index.test-d.ts Outdated Show resolved Hide resolved
index.d.ts Outdated Show resolved Hide resolved
Copy link
Owner Author

@gr2m gr2m left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question, looks great otherwise! Super cool that you got it all working!

gr2m and others added 2 commits July 15, 2021 16:16
Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
Copy link
Owner Author

@gr2m gr2m left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops sorry I forgot to submit my review yesterday

Comment on lines +38 to +40
type ConstructorRequiringVersion<Class extends ClassWithPlugins, PredefinedOptions> = {
defaultOptions: PredefinedOptions;
} & (PredefinedOptions extends { version: string }
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay to hard-code the required version right now, but we should make this flexible later.

The Base.Options interface can be extended with other required options, and these should be taken into account. Also the version is random and I just put it in here for exploring the blockers I ran into with Octokit, I don't think it makes sense to have any default options in the Base class, they should all be added when using Base by extending the Base.Options interface

I'll create a follow up issue, I think this PR is big enough as-is 😁

@gr2m gr2m changed the title feat: inher constructor options via Base.defaults() feat: derife constructor options from chainable Base.defaults() calls Jul 17, 2021
@gr2m gr2m merged commit 04f2454 into main Jul 17, 2021
@gr2m gr2m deleted the defaults-options-inheritance branch July 17, 2021 00:06
@github-actions
Copy link

🎉 This PR is included in version 3.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@gr2m gr2m changed the title feat: derife constructor options from chainable Base.defaults() calls feat: derive constructor options from chainable Base.defaults() calls Jul 17, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants