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

Use interface instead of type for all Base types #259

Merged

Conversation

Saeris
Copy link
Contributor

@Saeris Saeris commented Nov 19, 2023

See #257 for background.

While this PR touches many files, the bulk of the incoming changes here simply convert type intersections to interface declarations. The intent here is to enable third-party developers to easily pattern match against BaseSchema/BaseSchemaAsync when building type guards, or further refine types to specific schema subtypes such as NumberSchema.

In resolving type errors that came up as a result of these changes within Valibot's codebase, I also made some improvements to the implementations of some utility functions such as getDefault and getFallback. These functions and their related types were simplified while improving their own internal type safety.

Please read the comments below for more context.

Copy link

netlify bot commented Nov 19, 2023

Deploy Preview for valibot ready!

Name Link
🔨 Latest commit 3b2d314
🔍 Latest deploy log https://app.netlify.com/sites/valibot/deploys/656f63ba27315e00081aa1b6
😎 Deploy Preview https://deploy-preview-259--valibot.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@fabian-hiller fabian-hiller self-assigned this Nov 19, 2023
@fabian-hiller fabian-hiller added the enhancement New feature or request label Nov 19, 2023
@fabian-hiller
Copy link
Owner

Please have a look at #257 (comment)

Using some basic type predicate functions for base schemas, object schemas, and tuple schemas, I refactored the fallback and default methods which simplifies both their types and the logic for each method.

Note that because each of these methods relied on a mutable return value typed as `any`, Typescript did not catch any logic errors when calculating default or fallback values. With the refactors, in some cases an `as` type assertion is necessary.

For some of the method types, I reorganized them such that they no longer introduce a circular dependency (even if only at the type level).
Copy link
Contributor Author

@Saeris Saeris left a comment

Choose a reason for hiding this comment

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

Added some context for the refactors I did tonight. Let me know what you think.

library/src/methods/fallback/fallback.ts Show resolved Hide resolved
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Both of these interfaces could be simplified because the Output<> utility type would just extract the TOutput type that the BaseSchema was instantiated with.

// Otherwise, check if schema is of kind object or tuple
// If it is an object schema, set object with default value of each entry
if (isObjectSchema(schema)) {
return Object.entries(schema.entries).reduce(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This could stay a for loop instead of a reduce. I did it this way out of habit. One thing to note is that Object.entries() here is preferable because the in operator can have unexpected results due to it including properties from an object's prototype chain.

See difference between for...of and for...in

Copy link
Contributor

Choose a reason for hiding this comment

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

If using Object.fromEntries in the async version, why not use it here as well? Is it because of the performance cost of creating an extra array?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I suppose it can be done with Object.fromEntries here too, since in either case a type assertion is required. I implemented one after the other and solved for type errors along the way, so these were just the solutions I arrived at then. Will make this change when I resolve merge conflicts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it's really necessary I can add a test for this type predicate function. It already has coverage thanks to the tests for the refactored methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a utility I use in my own libraries. I can copy over the unit test for this too after an initial review.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can add an explicit unit test for this type predicate but I believe it is already well covered by the schema method tests.

It may be worth adding two more functions for Validations and Transforms as a utility for third-party library authors.

@fabian-hiller
Copy link
Owner

I will try to take a closer look at this PR this weekend and give you feedback afterwards. Please don't put too much work into this PR until it's clear how we're going to proceed. I deliberately chose types instead of interfaces initially because I had negative experiences with them in Modular Forms.

In each case here, `TRest` can possibly be `undefined`, which means the resulting type intersection `(BaseSchema | BaseSchemaAsync) & TRest` where `TRest` is `undefined` causes the whole intersection to be `undefined`.

To solve for this, we can instead compare `TRest` to `NonNullable<TRest>` to narrow it down to `BaseSchema | BaseSchemaAsync`.
This is a bugfix for an issue on main
@fabian-hiller
Copy link
Owner

I had a look at this PR and want to thank you for the work you put into it. In general I prefer to use type, but in the case of Valibot it may make sense to switch to interface for some definitions. Before I make a decision, I would like to hear your opinion on #258 and what advantages you see in interface and if you have encountered any disadvantages during implementation.

@fabian-hiller
Copy link
Owner

Can you respond to my previous comment and explain all the advantages you see in interface and also address possible disadvantages?

@Saeris
Copy link
Contributor Author

Saeris commented Dec 4, 2023

Can you respond to my previous comment and explain all the advantages you see in interface and also address possible disadvantages?

Hey, I intend to get back to you this week. I was busy this past week studying for a Japanese language exam. Sorry for the delay!

@fabian-hiller
Copy link
Owner

No problem, there's no rush.

@Saeris
Copy link
Contributor Author

Saeris commented Dec 5, 2023

Okay, here are a few reasons why I think interface is preferable to type in a library like Valibot:

  1. To speak directly to the original issue, this change is being proposed as a way to help 3rd-party library authors to build their own libraries on top of Valibot. This is because interface supports Declaration Merging, an important feature that provides that developer a means to add new functionality to Valibot with their library without necessitating the end user to adopt usage of a type alias when consuming the library. Which could be further complicated if multiple libraries that extend Valibot are used, where this quickly becomes a mess for the end user to unravel. I think Declaration Merging has a good use-case too in that an end-user can add support for their project that might not make sense to contribute upstream to Valibot itself.

  2. Another consideration from Types vs Interfaces in the official docs is that:

    Interface names will always appear in their original form in error messages

    I think it's important for the developer experience that when an error crops up, you can trace it back to its source quickly. Because of this behavior with interface names always being present is that at a glance, you know where to look next when a type error occurs. Contrast that with type (and in my experience this was historically an issue with type intersections especially) in that you might not know where the type conflict originates. When there are multiple type intersections in the chain, especially with arbitrary anonymous types, you'll have to dig for the property you have the conflict with.

  3. There is a perf cost associated with the two approaches where the type checker is concerned (meaning, it could affect editor tooling). I ran tsc --noEmit --extendedDiagnostics on my library discordkit, which I am refactoring away from zod, and compared that build to a local build of this PR (w/Interfaces) and v0.21.0 (w/Types) as a benchmark. One significant difference I noticed between these 3 builds is that w/Interfaces has less that 1/5th the Instantiations than the w/Types build does, and is about 30% less than w/Zod. The rest of the differences aren't as pronounced, though the Check Time for w/Interfaces was about 1s faster than the other two.

    w/Zod w/Interfaces w/Types
    Files: 2055 2172 2172
    Lines of Definitions: 314503 351242 351142
    Lines of TypeScript: 21216 22891 22891
    Lines of JavaScript: 302 302 302
    Lines of JSON: 0 0 0
    Lines of Other: 0 0 0
    Identifiers: 476096 434391 433892
    Symbols: 643182 525129 522803
    Types: 158165 71237 69277
    Instantiations: 1338374 943901 5405629
    Memory used: 800925K 658503K 676255K
    Assignability cache size: 67049 19106 20834
    Identity cache size: 1486 746 632
    Subtype cache size: 986 1744 1869
    Strict subtype cache size: 225 227 599
    I/O Read time: 0.18s 0.20s 0.21s
    Parse time: 1.01s 1.03s 0.99s
    ResolveTypeReference time: 0.02s 0.02s 0.02s
    ResolveLibrary time: 0.02s 0.01s 0.02s
    Program time: 2.02s 2.88s 2.89s
    Bind time: 0.53s 0.54s 0.57s
    Check time: 3.28s 2.22s 3.52s
    printTime time: 0.00s 0.00s 0.00s
    Emit time: 0.00s 0.00s 0.00s
    Total time: 5.83s 5.64s 6.98s

    But i don't have a good understanding of what impact Instantiations has on the performance of the type checker. Personally I have noticed that the w/Types build has significantly slower IDE tooltips than w/Zod, which isn't present in w/Interfaces, but I'm unsure if this is related or not.

    One other benchmark I ran was to compare the output of tsc --noEmit --generateTrace ./trace on w/Types and w/Interfaces so that I could go into Perfetto and drill into the stack traces. One of note was comparing the checkSourceFile trace on discordkit/packages/client/src/channel/__tests__/getchannelinvites.spec.ts where the execution duration on w/Types was 205ms vs 24ms on the w/Interfaces build.

    image

    Now I don't know much about perf testing the Typescript compiler much more than what I could read in a day, but the comparison that comes to mind for me is that if I were playing say, Dota 2, I would be at a significant disadvantage with a ping of 200ms vs my opponents if theirs was only 25ms. If this difference in checkSourceFile (which was consistently longer for all schema related files in w/Types) has any impact on the responsiveness I was experiencing with my IDE, then I think it's reasonable to assume that switching to using interface here (or some other related change I made) is having a meaningful impact on the developer experience of using Valibot.

Generally, the advice I've been seeing lately is, interface is still preferable to type for describing the shape of an object in TypeScript, especially if you're extending objects and/or you are authoring a library. In other contexts, it's more a matter of personal preference. If it's your own application code, fine, but if its library code, then it could create perf issues for consumers.

I couldn't find much supporting evidence for the performance impact of extends vs intersections (specifically coupled with generics), but my hunch is that TypeScript doesn't always cache type intersections (historically this was an issue) when generics are involved, so the pattern of BaseSchema & { ... } might cause a lot of extra instantiations. More digging is required on the huge difference in that metric. One example that did come up was a perf optimization where you need to extract out a complex conditional to its own named type so it can be cached. I had seen something similar in another article where an intersection was involved too, but I seem to have lost the link. I'll add it if I can find it. tl;dr was, if your intersection includes conditional types, extract the conditional to a named type with a generic, and intersect your base type with that type instead of doing it inline.

This article by the tRPC team was also an interesting read, and I recommend checking it out as got really buried in search results.

Another insightful article pointed out that Unions | and Intersections & do not behave like logical OR || or AND &&, which can result in some unintended gotchas. Objects defined via type also implicitly have an index signature, where interfaces do not, which can also lead to unexpected results.

@fabian-hiller
Copy link
Owner

Thank you for your detailed response. I will examine the code and make a decision by the end of the month.

@fabian-hiller
Copy link
Owner

Short update: I wasn't able to review this PR last month. It's planned to do so in the coming weeks.

@ariskemper
Copy link
Contributor

Interfaces vs Types in TypeScript

Declaration Merging:
Interfaces: Can be merged. This is useful when you're extending existing interfaces or working with third-party TypeScript definitions.
Types: Cannot be merged. Each type alias is unique.

Extending:
Interfaces: Easily extended using the extends keyword, promoting reusability.
Types: Can be extended using intersections, but it's not as intuitive as with interfaces.
Implementation Specifics:

Interfaces: More suited for defining public API's of classes.
Types: Better for complex type transformations or when using union types.

Readability and Clarity:

Interfaces: May be more readable and clear for object-oriented patterns.
Types: Sometimes more concise, especially with complex type relationships.

Performance Considerations
In terms of performance, there is no significant difference between interfaces and types in TypeScript. TypeScript compiles down to JavaScript, and both interfaces and types are used for type-checking during development and do not exist in the compiled JavaScript code. Therefore, they don't have a runtime performance impact.

Recommendations
Use interfaces when you need to define the shape of objects and you anticipate extending these shapes or integrating with third-party type definitions.
Use types for more complex type transformations or when working with unions and intersections.
Consistency is key. Stick to one approach within a project as much as possible to maintain readability and ease of maintenance.

@Saeris
Copy link
Contributor Author

Saeris commented Jan 5, 2024

Performance Considerations In terms of performance, there is no significant difference between interfaces and types in TypeScript. TypeScript compiles down to JavaScript, and both interfaces and types are used for type-checking during development and do not exist in the compiled JavaScript code. Therefore, they don't have a runtime performance impact.

@ariskemper This is inaccurate, because we are talking about Authoring Time and CI Time performance (IDE tooltips, typechecking), in which there are differences between the two. This PR is about improving the developer experience, which includes perf improvements that affect how responsive their coding environment is.

@Saeris
Copy link
Contributor Author

Saeris commented Jan 5, 2024

I'll give this PR an update pass this weekend to merge in the recent changes on main.

/**
* Schema with maybe default type.
*/
export interface SchemaWithMaybeDefault<TInput = any, TOutput = TInput>
Copy link
Owner

Choose a reason for hiding this comment

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

Please move this into getDefaults.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I moved these types because they created a circular dependency reference. IMO they are better kept in this file.

Copy link
Owner

Choose a reason for hiding this comment

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

Why does it create a circular dependency reference? I prefer to move it to getDefaults.ts to be consistent with the rest of the source code. Types that are related to a particular function are placed above the function in the same file.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

because for example:

export type DefaultValue<
  TSchema extends SchemaWithMaybeDefault | SchemaWithMaybeDefaultAsync,
>

in types.ts would import from getDefaults.ts, which itself imports DefaultValue from types,ts

Copy link
Owner

Choose a reason for hiding this comment

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

I will have a look at it. I don't think this is a problem but I could be wrong.

/**
* Schema with maybe default async type.
*/
export interface SchemaWithMaybeDefaultAsync<TInput = any, TOutput = TInput>
Copy link
Owner

Choose a reason for hiding this comment

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

Please move this into getDefaultsAsync.ts

library/src/methods/getDefaults/getDefaults.ts Outdated Show resolved Hide resolved
/**
* Schema with maybe fallback type.
*/
export interface SchemaWithMaybeFallback<TInput = any, TOutput = TInput>
Copy link
Owner

Choose a reason for hiding this comment

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

Please move that into getFallback.ts.

/**
* Schema with maybe fallback async type.
*/
export interface SchemaWithMaybeFallbackAsync<TInput = any, TOutput = TInput>
Copy link
Owner

Choose a reason for hiding this comment

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

Please move that into getFallbackAsync.ts.

library/src/utils/isSchema.ts Outdated Show resolved Hide resolved
@fabian-hiller
Copy link
Owner

I will review this PR as well as #451 today and tomorrow and decide again if I want to change to interface.

@Saeris
Copy link
Contributor Author

Saeris commented Mar 4, 2024

Just merged in the latest from the main branch, will address your feedback and requested changes as soon as I have a chance.

This latest commit adds the hasType utility we discussed in #250 (I may need to resolve some linting errors)

Copy link
Owner

@fabian-hiller fabian-hiller left a comment

Choose a reason for hiding this comment

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

There are a few minor details we need to work out, but otherwise this PR seems ready to merge.

library/src/methods/fallback/fallback.ts Outdated Show resolved Hide resolved
library/src/methods/fallback/fallbackAsync.ts Outdated Show resolved Hide resolved
library/src/methods/getDefault/types.ts Outdated Show resolved Hide resolved
@fabian-hiller
Copy link
Owner

I will do a final review later and probably merge this PR. 🙌

@fabian-hiller fabian-hiller force-pushed the feat/use-interface-for-base-types branch from c2570d6 to de1d857 Compare March 6, 2024 19:46
@fabian-hiller fabian-hiller merged commit f6fe5a2 into fabian-hiller:main Mar 6, 2024
6 checks passed
@fabian-hiller
Copy link
Owner

v0.30.0 is available

let defaults: any;

// If schema contains a default function, set its default value
>(schema: TSchema): DefaultValues<TSchema> | undefined {
Copy link

@aboqasem aboqasem Mar 16, 2024

Choose a reason for hiding this comment

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

Hello, as per the documentation for getDefaults:

The difference to getDefault is that for objects and tuples without an explicit default value, this function recursively returns the default values of the subschemas instead of undefined.

Did that change?

Copy link
Owner

Choose a reason for hiding this comment

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

No, it should work just the same. All unit tests passed, so I do not expect any bugs, but maybe we are missing something. Please create an issue if you encounter any problems.

Choose a reason for hiding this comment

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

Ok thanks. It just broke the type when I upgraded so I was wondering.

Copy link
Owner

@fabian-hiller fabian-hiller Mar 19, 2024

Choose a reason for hiding this comment

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

Feel free to share your code if you thing something is wrong on our side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request priority This has priority
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants