#Declaring getter + setter of mixed type

41 messages · Page 1 of 1 (latest)

terse belfry
#

Hi,
I'd like to express the following purely on type level (= no implementation):
"a class Foo has property x. When you read x, you will always get a string. But you can put both a string or a number into x"

I know that I can solve this on implementation level with getters and setters:

class Foo {
    get x(): string { return '' }
    set x(x: number | string) {}
}

const foo = new Foo()
foo.x = 42
foo.x = '42'
const fooString: string = foo.x

but this means I have to provide a (stub) implementation for the getter and setter, as they can not be declared, and I have to do this for every property of this kind.
I would prefer something like

type Magic<P, R> = { set (x: P), get: R }

class Foo {
  x: Magic<number | string, string>
  y: Magic<number | string, string>
  z: Magic<number | string, string>
}

But I have not found anything like that so far.
I tried indexed access types, but that only seems to "copy" the getter:

class Bar {
    declare y: Foo['x']
}

const bar = new Bar()
bar.y = 42  // Type 'number' is not assignable to type 'string'.(2322)
bar.y = '42'
const barString: string = bar.y

any advice is welcome.

gritty warren
#

you could use an interface instead of a class:

near warrenBOT
#
interface Foo {
    get x(): string
    set x(x: number | string)
}

declare const foo: Foo
foo.x = 42
foo.x = '42'
const fooString: string = foo.x
terse belfry
#

seems like a good first step! But I'm afraid I am bound to using classes for now

gritty warren
#

how come?

#

the type you get when you write class Foo {} is just an interface. there's nothing special about it. you can think of class Foo {} as shorthand for interface Foo {}; const Foo = class {}

terse belfry
#

tbh, I am not even 100% sure. I just remember I tried switching from classes to interfaces and even types a few times and something always turned out to be a show stopper.

gritty warren
#

if you can share an example i might be able to help you through whatever problem(s) you hit

terse belfry
#

ah, right. I am generating classes to be used in an aspect oriented fashion:

function A<Base extends new (...args: any[]) => object>(Base: Base) {
  return class extends Base {
    // some properties
  }
}

function B<Base extends new …>(Base: Base) { … }

class C extends A(B(class {}) { … }

and for that to work I need actual classes

gritty warren
#

hmm, seems like you do need an actual Foo value to exist at runtime then? so it's not actually "purely on type level"?

#

does the Foo class need to respond to .x? what do you want to happen if someone writes this?

const foo = new Foo()
const whatAmI = foo.x
terse belfry
#

yes, I do need an actual Foo at runtime. But that Foo is actually not generated via tsc, but is supplied in another way.
To be more precise: the actual Foo already exists, I am artificially supplying types for that existing Foo

gritty warren
#

is it that the actual Foo will exist at runtime but doesn't exist in your source code?

#

(so tsc doesn't know about it?)

terse belfry
#

kind of. We are providing a framework. Users can define a model for this framework in a DSL. The framework will read these DSL files and generate the actual "things" in the form of POJOs. But our users want to use them together with TS. As we can not deliver the types for the model the user comes up on site, we instead supply a tool that generates artificial types for the POJOs. These types are not actually used, but they satisfy tsc and provide intellisense.

gritty warren
#

sounds like you might want to be spitting out .d.ts files instead of .ts files, then? i'm not 100% sure i have the right thing in mind; does that sound plausible?

terse belfry
#

it sounds plausible and yes, I started out with .d.ts files. But since I needed to incorporate partial implemention (the function bodies for the aspect functions), I had to migrate over to generate .ts. I am still trying to have as little concrete logic as possible, so all properties are still declared, not actually initialised.
But I believe you understood the situation.

#

i.e.:

function A<Base extends new (...args: any[]) => object>(Base: Base) {
  return class extends Base {
    declare string foo  // just declared for tsc, but no actual value attached
    declare number bar
  }
}
gritty warren
#

so imagine you do go with an approach that can spit out a partial implementation. if generated code is this:

class Foo {
  partiallyImplementedThing = 'bar'
}

and then in the user code that is supplied on-site, they write this:

class Foo {
  somethingTheyImplemented = 'baz'
}

how do you connect those two? do you expect the "real" implementation to extend the generated one or something like that?

#

or maybe the on-site one is written using that A function, with the generated Foo supplied as Base?

#

if either of those ☝️ sounds correct, perhaps what you want is an abstract class:

near warrenBOT
#
abstract class Foo {
    abstract get x(): string
    abstract set x(x: number | string)
}

declare const foo: Foo
foo.x = 42
foo.x = '42'
const fooString: string = foo.x
terse belfry
#

I'm afraid not. The types I generate are pure fabrication. While they look and feel just like the thing the user will receive during runtime, they are completely disconnected from the actual thing. Just their type is similar enough for the user to use.
What a user typically would do is something like this:

// our DSL
thing Foo:
  x is a string

and have a handler like this:

import * as fw from `@our/framework`
const Foo = fw.model('Foo')

fw.handleAll(Foo, foo => {
  console.log(foo.x)  // .x needs to be known to be a string
})

as you can see, the user never actually comes in contact with the structure of X on JS level. They define it in our DSL. And they create handler code for it. And inside that handler code, they should have intellisense for the properties of their model. We achieve this by putting these generated classes in places that tricks the TS LSP into using these classes to supply intellisense for Foo

gritty warren
#

hmm, i guess i don't understand where the implementation of x actually comes from then. is it something generated by fw.model or fw.handleAll? if so it seems control is entirely in your hands and in that case i don't grok the "it must be a class" requirement

would it be possible to share a complete/self-contained example of the problem in the context of your actual library (or a simplified facsimile of it)?

terse belfry
#

well, yesn't. "We" (as in: the company I work for) are in complete control over how fw.model spits out x. But "I" (as in: the guy tasked with shoehorning TS into this mature framework) can not just change the implementation of fw.model, that is owned by another team

gritty warren
#

ah, that helps

#

(also: i feel your pain 😂)

#

if you can't share a complete example, could you at least be specific about what problem would happen if fw.model's return type is written like this:

interface Foo { … }

rather than this?

class Foo { … }

(assuming the already-working runtime implementation of fw.model is not changed at all)

terse belfry
#

going back to the aspect function:

function A<Base extends new (...args: any[]) => object>(Base: Base) {
  return interface extends Base {
    declare foo: string
    declare bar: number
  }
}

is not a valid thing to do

gritty warren
#

i don't think i understand how A fits into the rest of this. can you show me code using fw and A and Foo?

#

is A used in the implementation of fw.model?

terse belfry
#

oh, sorry, I accidentally used two nomenclatures. A is actually supposed to be Foo. The actual output I generate looks pretty close to this:

export function Fooƒ<Base extends new (...args: any[]) => object>(Base: Base) {
  return interface extends Base {
    declare x: string
  }
}

export class Foo extends Fooƒ(Object)

the class Foo reflects the type-level guise of how the user defined their model in our DSL:

// our DSL
thing Foo:
  x is a string

and since our DSL allows multiple inheritance, we generate the aspect function Fooƒ, so we can model diamond shaped inheritance. But in handler code, users will receive Foo through fw.model and consequently get intellisense for that type.

gritty warren
near warrenBOT
#
export interface Foo {
  get x(): string
  set x(x: number | string)
}

export function Fooƒ<Base extends new (...args: any[]) => object>(Base: Base) {
  return class extends Base {}
}

export class Foo extends Fooƒ(Object) {}

const foo = new Foo()
foo.x = 42
foo.x = '42'
const fooString: string = foo.x
gritty warren
terse belfry
#

huh, what the Deuce? I had tried declaration merging right away when you first suggested the interface approach and it just gave me a "duplicate identifier" error at the time. But now it seemingly works like a charm

gritty warren
terse belfry
#

ah, I had left in x in the generated class as well, which caused the duplicate identifier!

gritty warren
#

sneaky

terse belfry
#

anyway, this seems like a path I can follow. Thank you very much for the patience and suggestions 🙂

#

!resolved