#Generics question: What type to use in a variable when type is inferred

81 messages · Page 1 of 1 (latest)

quasi magnet
#

Hello guys, I am working on a project which is a monorepo, it has:

- apps
  - webapp
- packages
  - lib1
  - lib2
  - types (shared among all projects in monorepo)
  - main

The thing here is types has a generic type, let's call it Generic<A> with this structure:

type Generic<A> = {
  field: string, 
  inner: A
}

So each library (lib1, lib2, etc...) is using Generic with it's own specific type, let's say:

export type InnerLib1 = {
  fieldOfLib1: string, 
} 

const internalMethodFromLib = ():Generic<InnerLib1> => ...

export default {
  internalMethodFromLib
} satisfies ...

And the main library, which kind of consolidates as an entry point for all lib x, has a method that returns the Generic<?> according to some pattern matching, for example:

const someMethod = (param: 'n1' | 'n2') => match(param)
      .with('n1', () => lib1)
      .with('n2', () => lib2)
      .run()

Everything works well and in webapp project I am calling to someMethod passing param to get an object which returns all methods exported from lib1 or lib2, etc.

// This returns the Generic<InnerLib1>
const lib = someMethod('n1')
lib.internalMethodFromLib()

// This would return Generic<InnerLib2>
const lib = someMethod('n2')
lib.internalMethodFromLib()

My problem now is what type assign to the call lib.internalMethodFromLib() because it can be Generic<InnerLib1> or Generic<InnerLib2> or any other inner type that is not available in webapp project, I know there is no way to do something like Generic<_> and I am not sure what options I have here

heady blaze
#

or any other inner type that is not available in webapp project
feel like this is the problem imo

#

if your lib uses some types in a way that it also exposes them to its consumers, those types should be re-exported

#

or the source of those types should be a peer dependency

#

how could one use your lib if they don't know about the types that lib could return?

quasi magnet
#

yeah that's a good point, maybe creating a new union type consolidating all types in main so each app (in this case webapp) can use it and the compiler will infer which type it is?

#

and I think this approach would be cleaner because those types are just specific for their libraries so putting them all together is not needed because all libs share some types from a package types but those are not specific types

#

those lib1, lib2, etc are not going to be used alone, they are being "proxied" thorugh main package, so I think it's ok if main package exports a 'common' type that is the union of the types those functions can return

heavy spear
#

But you really have an architectural failure

#

You want to avoid functions that return information without being specific about what type it is.

#

The means either using static connections like standard imports, or using generics/inference. If you want to be able to use a generic to return the correct type when users can add their own types, then you need declaration merging probably

#

You'd have an interface like inferface InternalMethodTypes { lib1: InnerLib1, lib2: InnerLib2 }

#

And it could be extended like declare module 'types' { interface InternalMethodTypes { lib3, OuterLib3 } }

#

Generally you just want to use static imports. I'm not sure why you're not doing that here.

quasi magnet
#

you are right, I am going to rethink a bit the solution

#

I am using static imports everywhere, it's just the final step where I have not put one because it could be more than one so a union can introduce the static import

#
// This returns the Generic<InnerLib1>
const lib = someMethod('n1')
lib.internalMethodFromLib()

The whole problem is that lib should be just one type and internally manage all generics, so instead of being Generic<InnerLib1> this should be Lib because all calls are common, so there is no need to do something like this, and for extra data I can manage them as Record because it is dynamic, I don't exactly what is inside, could be anything but always key=value where key is always string and value is unknown

#

What I was trying to do is kind of forcing the extra data to have a type per library, so let's see say lib1 would have an extra data in some structures as Lib1Extra, and for lib2 Lib2Extra but doesn't matter because the types do not change, just on runtime

#

Thank you both

heavy spear
#

I really don't follow what you're trying to do

#

If you're doing

lib.internalMethodFromLib('n1')
lib.internalMethodFromLib('n2')

Then why not just import a method from lib1 and lib2, and have the correct typing?

If you want

const lib = someMethod('n1')
lib.internalMethodFromLib()

Then why not import * as lib from 'lib1'?

#

I guess there are some situations where you don't know when you write the code whether lib1 or lib2 will be required. Then as you say you might want to return a union Generic<InnerLib1> | Generic<InnerLib2>.

#

But as said earlier, if there can be additional libs created by a developer that could also be returned, then you need something like declaration merging to capture those additional types

quasi magnet
#

Let me explain it better

The idea is that you as a developer can add another library without having access to the mainwebapp, for that you have to follow some kind of protocol/contract/interface, thats where types will help. In the web app, users can access to a different library using the "proxy" the main lib that "proxies" the other libraries, so as soon a team finished another lib, let's say lib3, that lib is connected to main just importing the entry point from lib3 and adding it to the match:

const someMethod = (param: 'n1' | 'n2') => match(param)
      .with('n1', () => lib1)
      .with('n2', () => lib2)
      .run()

Then webapp is compiled and that's it, in runtime user can change n1,n2,n3 and they access to the set of specific methods of each library

heavy spear
#

Right

#

And if you add a library n3, that satisfies the constraint, do you then use const lib = someMthod('n3') to load your lib? And is this purely as a way to enforce a contract?

#

If you didn't need to enforce a contract, you would just create it locally and import it?

quasi magnet
#

Absolutely, the idea is that other teams can work with just main package (to add their library) + types library + their lib (lib3), as soon as they finish integrating their lirbary (lib3) webapp (managed by other team) can continue working as expected because types are the same

#

So webapp team can recompile to include the new library (libe3) and support for libr3 is added to the webapp and the team creating the lib3 library didn't need touch any line of code of webapp

#

it's like a organized way to plug new libraries following the same types

#

Also I am trying to be as pure in fp as possible, no variables, classes, interfaces or anything, just types, consts, functions and fp libraries like fp-ts

heavy spear
#

I would think something like this

valid edgeBOT
#
type Lib = {
    foo: () => string,
    bar: (a: number) => number
}


// lib 1 package
export const lib1 = {
    foo: () => 'foo',
    bar: (a: number) => a,
    baz: () => 1234
} 
const _1: Lib = lib1


// lib 2 package
export const lib2 = {
    foo: () => 'foo',
    bar: (a: number) => a,
    a: 1,
    b: 2
} 
const _2: Lib = lib2


// lib 3 package
export const lib3 = {
    foo: () => 'foo',
    baz: () => 1234
} 
const _3: Lib = lib3
//    ^^
// Property 'bar' is missing in type '{ foo: () => string; baz: () => number; }' but required in type 'Lib'.
heavy spear
#
// consumer package
import lib from 'lib3'
#

It's slightly less foolproof, but it's much simpler

#

You can also do a check after importing

// consumer package
import lib from 'lib3'
const _: Lib = lib // type check
#

Otherwise, you can do the interface thing.

inferface Libs {
  n1: InnerLib1,
  n2: InnerLib2
}

function getLib<A extends keyof Libs>(a: A): Libs[A] {
  // match
}
// consumer project
declare module 'types' {
  interface Libs {
    n3: OuterLib3
  }
}
#

Not sure if TS gives you trouble with keyof Libs by collapsing it early, it might be OK.

heavy spear
#

--
Oh, can do it like this instead of the original example:

type Lib = {
    foo: () => string,
    bar: (a: number) => number
}

// lib 1 package
export const lib1 = {
    foo: () => 'foo',
    bar: (a: number) => a,
    baz: () => 1234
} 
lib1 satisfies Lib
quasi magnet
#

That's exactly how it is right now but the only difference was the generic:

type Lib<A> = {
    foo: () => Generic<A>,
    bar: (a: number) => number
}

// lib 1 package
export const lib1 = {
    foo: ():Generic<TypeLib1> => ...,
    bar: (a: number) => a,
    baz: () => 1234
} 
lib1 satisfies Lib<TypeLib1>

// lib 2 package
export const lib1 = {
    foo: ():Generic<TypeLib2> => ...,
    bar: (a: number) => a,
    baz: () => 1234
} 
lib1 satisfies Lib<TypeLib2>

Then the main lib has:

const someMethod = (param: 'n1' | 'n2') => match(param)
      .with('n1', () => lib1)
      .with('n2', () => lib2)
      .run()
heavy spear
#

But if you have that, why not just import it directly?

#

Without some method?

quasi magnet
#

there are N teams, webapp and lib teams (one per team)

heavy spear
#

I don't get the difference

#

The app team are going to have to change a line from getLib('n1') to getLib('n2') to switch to the new lib?

quasi magnet
#

For web app team, they don’t need to now how many libs are, they don’t change anything

heavy spear
#

Ah

#

So what determines the lib they use? The lib team?

#

And do they actually need to see the extra features of the lib, or do you actually want the extra features hidden?

quasi magnet
#

That’s dynamic, the user changes it

heavy spear
#

Ahh

#

So you really do want a union

#

The webapp team has to deal with not knowing which lib is going to be active right?

quasi magnet
#

Exactly

heavy spear
#

I see now!

quasi magnet
#

So the union it’s the way right

#

?

#

What do you think?

heavy spear
#

Well, if you have 3 libs, then a union of those 3 is the most accurate info right?

#

But do you have an issue that you don't know now what extra libs will be added?

quasi magnet
#

They are going to be added to support more apis, so there is no limit, there are thousands of them

#

We are not going to add a thousand but we will def add more than 3 soon or later

heavy spear
#

So if you have 1000 libs, and they all follow the contract

#

Are you not designing the contract so that the webapp only needs the functions that exist on the contract?

quasi magnet
#

Yes, that’s the idea, but each team is free to code the content in the way they want

heavy spear
#

Or does the web app have to handle some edge cases using extra functions per lib?

#

So you don't even need a generic, if the web app doesn't need to know about anything but the public contract

quasi magnet
heavy spear
#

Right I see

quasi magnet
#

If at some point the case appear it’s already there

heavy spear
#

Yep yep

quasi magnet
#

That was the idea

heavy spear
#

Good

#

So yeah use a union

#

You probably want to give each lib a discriminant: e.g. a "type" property with a unique string name, so the webapp can check for the type that is in use, and TS can do it's narrowing

#

So long as the lib team knows what libs it is implementing and can list out a union, that's the answer.

quasi magnet
#

Yeah you are right and thanks for taking the time on this!

heavy spear
#

Hey no worries

#

I was a bit slow to understand it 😅