#Ts object method spillage

84 messages · Page 1 of 1 (latest)

strange echo
#

I have a type that defines an object wh's keys are the nested keys of another type and who's values are ether the same type as the original object, or a set object type.

export type SQLMatchers<Schema extends object> = {
  [Key in NestedKeyOf<Schema>]?: NestedValue<Schema, Key> extends Date
    ? Date | SQLMatcherOperators
    : NestedValue<Schema, Key> | SQLMatcherOperators;
};

This type works great for what I want to do, my issue comes with the date, The original object values can ONLY be primitives or a Date object, which, for the primitives works fantastic since ther you set a propert directly to a primitive value, or if you set it to an object it WILL be the SQLMatcherOperators object.

My issue comes from date, since the Date constructor does resolve to an object, Ts does not diferenciate and spills it's properties into my SQLMatcherOperators object.
to resolve this issue (at least theoretically) I would replace the union between Date and SQLMatcherOperators to be something like a OR operator, but so far I've found nothing of the sort.

I'll use this example:

const ExampleSchema = new SQLSchema<History>(
  {
// ... other properties
  details:{
    // ... other properties
      newValue: String,
      test: Date,
    },
  }
);

So given that schema, the matchers can be defined as follows:

 const result = await ExampleModel.findOne({
      "details.newValue": "foo",
      "details.test": new Date(),
    });

where:
(property) "details.newValue"?: string | SQLMatcherOperators
and
(property) "details.test"?: Date | SQLMatcherOperators

All fine so far, the thing is once you use the matchers object, in the case of string it's image[1], perfect, but in case of date it's image[2]

I've tried setting the type to an "invisible date" where I set the object as date, but i omit it's keys, but that did not work bc TS decided that is equal to any type.

glacial crow
#

ts is structurally typed, so anything that has all of Date's members is a valid Date. that's why it's suggesting Date members, in case you wanted to make one right there, which of course you don't, but ts doesn't know that.

strange echo
#

I understand, I actually use that feature quite a bit, but there has to be a way to allow a Date object to be set, but hitde it's methods no?
As I said, setting the type to
ts Omit<Date, keyof Date> kinda acomplishes that, the issue is that it then allows any literal type to be set in place of a Date object, like string number, or even other objects

pulsar vigil
#

If you omit the keys of a date object, then it's no different from any other object.

strange echo
#

Yes, I do understand that this solution is bogus and does not work, I'm just setting it as an example to give a better understanding of what I'm after. Althoug that explanation, as simple as it is, did clarify why it does not work! xd

glacial crow
#

something like a OR operator
that's what union models

pulsar vigil
#

What are you confused about, that Omit<Date, keyof Date> allows other objects?

#

Or that it allows even numbers and strings?

strange echo
#

But that's aside of my main issue

glacial crow
#

a Date that doesn't require any properties is just something you can index into, ie {}
you can index into primitives, courtesy of js autoboxing, so primitives fulfill {}

pulsar vigil
#

Take a look at this example:

const x: { toString(): string } = 42

Should this code compile?

strange echo
glacial crow
#

that is an or

#

it's just not an xor

strange echo
#

true mb

glacial crow
#

{ foo: string } | { bar: string } means foo must be there, or bar must be there
it doesn't exclude the case where both are there

strange echo
#

Then is there an XOR option (I did not find any online)

pulsar vigil
glacial crow
#

not as an operator, but you can effectively have XOR by removing the "both" case, with disjoint types, ie with a discriminator

#

a general XOR would require negated types

strange echo
glacial crow
#

technically primitives don't have members

#

their wrappers do

#

when accessed, they're autoboxed into their wrappers in order to get those members

strange echo
glacial crow
#

they do, that's how they behave in js

#

String is the wrapper class for string, etc

strange echo
#

y

#

I agree with that you said ^^

#

Anyway, no XOR will mean basically ugly intellisense, nothing else so It's not that bad of an issue I guess. I do have a tendency to get into rabitholes trying to "fix" things that are not broken, just to have them the way I want them, not my best quality. I appreciate the responses and clarifications, I do now have a better understanding of how primities work, which I didn't expect to take from this, but it's very welcome

#

!resolved

glacial crow
#

you could wrap stuff in DUs if you wanted that XOR behavior

pulsar vigil
#

^

strange echo
#

I've never heard of DUs, mind expanding on this?

pulsar vigil
#

Besides the philosophical "why is 42 assignable to {}" there is a very practical reason too: what if you have an object that is both a Date but also a SQLMatcherOperators? How do you know which one it is?

#

A DU will ensure you know exactly which one it is with no ambiguity.

#

!hb disc

frank thunderBOT
strange echo
#

Will do a read, thanks so much. Also considering removing my "expert" role after this thread thonk

#

ahhaa

#

From what I see I don't think they will help, since I do already have discrimination code, just in JS, for execution, not in Ts types at "compile"

glacial crow
#

even with a lot of experience, ts is just really deep lol
most people active as helpers here use the "experienced" role

pulsar vigil
strange echo
#

My code does diferenciate between when it's a date and the SQL object.

glacial crow
pulsar vigil
#

Take a look at this example:

type Foo = { foo: string }
type Bar = { bar: string }

const obj = { foo: '', bar: '' }
fn(obj)

function fn(fooOrBar: Foo | Bar) {
    // ?
}
#

How do you write code to tell obj is Foo or Bar? You can't, because obj is both at the same time. The best you can do is make a decision "if something is both a Foo and a Bar, treat it like a Foo."

strange echo
#

I guess so, but in my example this "merging" won't ever happen, the only edge case would be if someone decides to write a bunch of Date methods manually and then also uses my matchers in the same object, which I mean If you wana try if my codse breaks go ahead, it probably will, so just don't xdd

Here is my discrimination logic, even without the types, I just don't think that it can get much simpler

I just treat the matcherDefinition as my object unsless it's not an object, date, or null

#
 private constructSQLMatchers(sqlMatchers: SQLMatchers<Schema>, options?: SQLMatcherOptions) {
    return Object.entries(sqlMatchers)
      .map(([schemaKey, matcherDefiniition]) => {
        const tableKey = this.schema.conversionMap[schemaKey as NestedKeyOf<Schema>] as string;
        if (!tableKey) throw new Error(`Can't find table key for schemakey ${schemaKey}`);

        if (typeof matcherDefiniition !== "object") {
          return `${tableKey} = '${matcherDefiniition}'`;
        }

        if (isDate(matcherDefiniition)) {
          return `${tableKey} = '${Model.toSQLDate(matcherDefiniition)}'`;
        }

        if (matcherDefiniition === null) {
          return `${tableKey} IS NULL`;
        }
        const operator = matcherDefiniition as SQLMatcherOperators;

        if (operator.$in) {
          let matcher = `(${tableKey} IN (${operator.$in
            .map((inValue) => `'${inValue}'`)
            .join(", ")})`;

          if (operator.$in.includes(null)) {
            matcher += ` OR ${tableKey} IS NULL`;
          }
          matcher += ")";

          return matcher;
        }

        if (operator.$matchString) {
          return `${tableKey} REGEXP '${operator.$matchString}'`;
        }

        if (operator.$inMatch) {
          return `${tableKey} REGEXP '${operator.$inMatch.join("|")}'`;
        }

        if (operator.$matchNumber) {
          return `CAST(${tableKey} AS CHAR) LIKE '${operator.$matchNumber}%'`;
        }

        // TODO: implement other operators
        if (operator.$after) {
          return `${tableKey} >${options?.useInclusive ? "=" : ""} '${Model.toSQLDate(
            operator.$after
          )}'`;
        }
        if (operator.$before) {
          return `${tableKey} <${options?.useInclusive ? "=" : ""} '${Model.toSQLDate(
            operator.$before
          )}'`;
        }

        // This should never be reached
        return "";
      })
      .join(` ${options?.useOr ? "OR" : "AND"} `);
  }
#

Basically just that said, the matcher actual implementation does not matter much

pulsar vigil
#

Yeah you basically made a decision that "if something is both a Date or a matcher, treat it like a Date"

#

Not saying this approach is wrong or that someone making a "both" object is a realistic concern, but just to demonstrate why you have the problem you initially asked in the thread.

#

A DU prevents this "both" problem because the discriminator ensures that an object can never be both.

strange echo
# pulsar vigil A DU prevents this "both" problem because the discriminator ensures that an obje...

To make a DU here, if i understand correclty, woul't I have to modify the Date prototype (very no-no in my book) to create this "shared" property tboth objects have to distinguish them? or does date already have something like isDate:true that I can use and then wrap my sqlMatchers object in a getter that adds the property isDate: false? something alone thse lines? Again, I've never worked with DUs, but from my limited reading of your material this is how I understood it

glacial crow
#

no, you would wrap it

strange echo
#

I guess so yeah. Since all this is basiocally for dev purposes, does not affect code, I do think having to create a date getter and an sqlMatcher getter just to have the type not show the date's properties, I think it defeats the purpose of allowing simple Date constructors in the first place, because if I wrap it I might aswell work with stringified dates directly

glacial crow
#
type DateField = { kind: "date", value: Date }
type StringField = { kind: "string", value: string }
type FieldMatcher = { kind: "match", value: SQLMatcherOperators }
```then you would use, say, `DateField | FieldMatcher` in place of `Date | SQLMatcherOperators`
so it's a union where each member has a discriminator (`kind`, in this case)
strange echo
#

I ether force the dev to type in all that extra, or I create a wrapper that does it automatically, which would then need another wrapper to not run in the same problem and so on xdd

glacial crow
#

which would then need another wrapper to not run in the same problem
you would not

#

why would you..?

strange echo
#

So if I sayt create something like a getMatcherField(value) where value is the original object's value, to then have something like a switch that gets the value type and sets it as the kind field, I would then run in the same problem, where the passed value can be ether Date or the SQLMatcherOperators object wouln't I?

#

Damn I said value so mutch it's confusing

glacial crow
#

no, you would just make separate functions for each kind

strange echo
#

I mean true, but again, not only more work for me, but, Imagine you are using it, would you rather that in the case that you match a document by date, some date methods spill in intellisense, which you can safely ignore, and write the object quickly with literals, or that for every single field you want to match you have to find that field's function and write it's name. I just don't think it's worth the hassle, even if I do admit this is an excelent way of solving my original complaint. I'm just not convinced it really does need solving if the solution is that much worse of a dev experience...

#

I have to run, just so you know I don't leave you hanging, thank you so much, to both of you for the help and clarifications

glacial crow
#

you have a single SQLMatcherOperators that's shared though?

strange echo
#

yes

glacial crow
#

yeah i don't know what issue you're referring to then

#

you have different functions for each type of field, not for each field

#

and intellisense would handle acquiring those functions just fine

strange echo
#

yes yes I understand, but still a: "foo" is more understandable and easier to a: stringMatcher("foo") no?

glacial crow
#

you could use something like equalsString("foo")

#

tbh i probably would have a different design given the foresight of non-exclusive union types, but i guess that ship has sailed

strange echo
#

The thing is why use a: equalsString("foo") when I can use a:"foo" to the same effect, and the only consequence is the intellisense thing. Again this is an excelent solution to what I first asked, and I might even consider implementing it in the future, I'm just not convinced that it's worth to trade the simplicity for the type assetion

strange echo
pulsar vigil
#

How many data types do you need to support?

strange echo
pulsar vigil
#

If you want to use number | string | Date | SQLMatcherOperators so you can pass number | string | Date directly, you can make that happen by giving SQLMatcherOperators a special way to identify it, which means you will have to use a wrapper like a: matcher(...) though.

strange echo
#

I'm creating this SQL interface for a company's CRM, which is private, but I very much do intend to release the SQL interface as an npm package. I can upload a beta version if you are curios as to how it works and why I designed it this way.

strange echo
pulsar vigil
#

You do have the "both" problem and that's why you have to cast.

strange echo
#

Yeash, but at the end of the day, my type cast is safe, so It's just not worth it in my opinion. I do however reserve the right to change it in the future, I just have much more pressing features to code, since this one, even if not type-perfect, works very reliably at runtime

pulsar vigil
#

I've given you a potential solution of using field: matcher(...) so you can still do field: 'foo'/field: new Date() without needing wrapper for those.