#Access generic value(s) within function

120 messages · Page 1 of 1 (latest)

dusky wharf
#

Hi,

We're building out a function to parse a string into an object and ideally use generics to define what is being returned.
Rather than trying to give loads of context etc, hopefully I can more easily explain what we're trying to achieve with an example:

type Obj = Obj1 | Obj2 | Obj3;

type Obj1 = {
  readonly version: 1;
  // custom attributes for Obj1
}

type Obj2 = {
  readonly version: 2;
  // custom attributes for Obj2
}

type Obj3 = {
  readonly version: 3;
  // custom attributes for Obj3
}

// possible example 1
function parse1<TObj extends Obj>(body: string): TObj {
  // how can I access/reference the TObj version as it dictates how I parse the string?
}

// possible example 2
function parse2<TObj extends Obj, TVersion = TObj["version"]>(body: string): TObj {
  // how can I access/reference TVersion as it dictates how I parse the string?
}

If anymore information is needed, I'm happy to give it.

Thanks,
Gary

cinder sable
#

You cannot, types don't exist at runtime.

#

fn<Foo>() and fn<Bar>() compiles to the same JS code.

dusky wharf
#

A workaround we had was this:

#
function parse<TObj extends Obj>(body: string, version: TObj["version"]): TObj {}
#

But that resulted in calls like this:

parse<Obj3>("", 3)

Which just seemed a bit weird

#

I assume there is no way of defaulting the value of version in this case?

cinder sable
#

No, again types don't exist at runtime, parse<Obj3>('') and parse<Obj2>('') both compile to the exact same parse('') JS code.

#

If you want to access something at runtime, you have to pass it as a runtime value in some way.

dusky wharf
#

So are workaround is potentially the only/best solution?

cinder sable
#

You can change your code in a way that you can just write parse('', 3) and don't have to repeat <Obj3>

dusky wharf
#

The advantage (I think) of using the generics here was so that it reduced the amount of narrowing required after calling that function

cinder sable
#

parse('', 3) can still narrow.

#

That also uses generic, the difference is that your current way of writing generic has no way for TS to infer, and that's why you have to manually specify <Obj3>.

#

You can write it in a way that doesn't need to.

dusky wharf
#

Are you suggesting it might just be easier to something like this?

function parse(body: string, version: Obj["version"]): Obj {}
cinder sable
#

Here's one way to write it:

short apexBOT
#
nonspicyburrito#0

Preview:```ts
type Versions = {
1: {foo: number}
2: {bar: string}
}

const parsers: {
[K in keyof Versions]: (input: string) => Versions[K]
} = {
1: input => ({foo: +input}),
2: input => ({bar: input.trim()}),
}

const parse = <K extends keyof Versions>(
input: string,
version: K
...```

tired leaf
#

The function returns one Object of Obj, so just return Obj and do the narrowing afterwards. No version parameter. It can return any type. You can't specify it before because it could in theory return other things

dusky wharf
#

Ah there's a reason for specificying the version, our function/library also support converting the version

cinder sable
# short apex

See how parse('', 1) returns a V1 result, and parse('', 2) returns V2 result, without you needing to specify it again like parse<V1>('', 1).

tired leaf
#

The nice thing with returning Obj is that it will give you an union of every possible value and you can then narrow it down to what it is/what you want

#

And since you have an identifier (the version), this is super easy too

cinder sable
#

You shouldn't need to narrow manually because parse('', 1) already gives TS enough information to narrow on its own.

#

Like my solution above has shown.

dusky wharf
#

Just wrapping my head around your example

tired leaf
#

Yeah, the thing is that the parser can return any Obj and we are specifing in the type system what it is. When running, however, it can be anything. I wouldn't recommend having this kinda split. If the example satisfies OP, that's fine. I'd do it otherwise though

cinder sable
#

It can't

#

How can parse('', 1) return anything but V1?

tired leaf
#

In your example it can't, that's right. How OP explained his situation, I don't think the parser works this way? If it does, don't mind me. To me it seems like the parser is one thing that returns a object based on the actual data in the string.

dusky wharf
#

To clarify, the string is 99% likely to be an XML string and we're trying to convert it into a typed object

#

So we don't know for sure what version it's going to be, the parse can either auto detect the version but the return would still be Obj and not narrowed by version... which when auto detected is fine

#

If we want to allow people to define what version to parse/convert it into it seems quite annoying to still have to narrow after calling the function despite telling the function which version to parse/convert it to

#

Does that make sense?

tired leaf
#

So currently the parser is generic and can return any possible Obj, but want the ability for others to define a specific way to parse the string based on a version?

dusky wharf
#

We currently have two function, one for parsing and one for converting

#

Convert currently looks like this:

public convert<TObj extends Obj>(obj: Ern, version: TObj["version"]): TObj {}
#

So to call it you would do the following:

const parsed = parse("some xml string");
const converted = convert<Obj1>(parsed, 1);
tired leaf
#

You can use the parse function in the example as your convert.
The conversion logic is inside the parsers object, where the key is the version and the value is a function that converts any Obj into the object that matches that version, if that makes sense.

dusky wharf
#

This is it, I was looking to consolidate to two functions

cinder sable
#

My understanding of the question is that, OP currently writes code like parse<V1>('', 1) and would like to avoid repeating the version. The original code parse<V1>('') is a potential solution they come up with which wouldn't work.

#

They are not trying to use parse<V1>('') as a way to replace parse(''): V1 | V2 | ... + narrowing.

dusky wharf
#

Pretty much, I think

tired leaf
#

Right, so the problem is no longer the parsing, it's having one conversion function that will auto convert any Obj into the specified version.

dusky wharf
#

I think it’s still the same problem, it’s just the input has changed from a string to a parsed object

cinder sable
#

You can just change the input: string to input: V1 | V2 | ... in my solution. The idea is the same.

tired leaf
#

Right. The example provided earlier does just that. You specify your version types in Versions with the keys being what identifies them (the version field). Then, specify the conversion logic in parsers

dusky wharf
#

As I say, the conversion function still does that but the only difference is they the input is a pre-parsed object

tired leaf
#

Change out the type of the values in parsers to (input: Versions[keyof Versions]) => Versions[K]. That way, the function takes in any of the defined pre-parsed objects and turns it into the version you want.

dusky wharf
tired leaf
#

Yes. The example creates a really easy way for you to add/remove/change conversion logic in the parsers object. Just define a function for each version of object you want to have. That function will take in any Obj and return the object with the correct version.

dusky wharf
#

Ok, I’ll continue to wrap my head around the example then, thanks both for the advice

#

I may be back with more questions if that’s ok?

cinder sable
#

Yeah follow up questions are fine.

dusky wharf
#

Ok, to clarify... to use your example it would mean I need to change my types slightly.

Currently I am defining this:

type Obj = Obj1 | Obj2 | Obj3;

And the example would mean changing to something like:

type Obj = {
  1: Obj1;
  2: Obj2;
  3: Obj3;
};

Is that correct?

cinder sable
#

Sure.

#

I mean you can still do:

type ObjVersions = {
    1: Obj1
    2: Obj2
    3: Obj3
}

type Obj = ObjVersions[keyof ObjVersions]
dusky wharf
#

Yeah, think I've just realised that

#

Been playing around with the ability of not requiring the version to be passed in which has led me down function/method overloading

cinder sable
#

FYI overloads are not type safe.

dusky wharf
#
  public parse(body: string): Obj[keyof Obj];
  public parse<K extends keyof Obj>(body: string, version: K): Obj[K];
  public parse<K extends keyof Obj>(body: string, version?: K): any {
    if (version) {
      return parsers[version](body);
    }

    return someGenericParser(body);
  }
#

That's fine, the point is if someone defines the version that it is typed correctly/accordingly

#

And it seems to work

#

The public in this example has propably given away that I'm using classes rather than functions

#

As far as I can tell, this is working as I'd expect/need it to

#

If I call parse("some string") it returns an Obj and I need then to do narrowing on it

#

If I call parse("some string", 3) it return an Obj3 and don't need to do anymore narrowing in regards to the version

cinder sable
#

Yes the types will be fine, it's just that your implementation will not get checked by TS whatsoever, so you are responsible to making sure that doesn't break even during refactor.

#

Your implementation signature can be simplified btw, since it's unchecked by TS.

dusky wharf
#

Is this because I'm using any?

cinder sable
#

That yes, but also you can just do:

parse(body: string, version?: keyof Obj)

And omit return type.

#

Implementation signature is hidden and you already don't have type safety with overload, you can just change it to whatever makes it convenient.

dusky wharf
#

Is there an alternative way to make version optional?

cinder sable
#

No.

dusky wharf
#

Ok, well at least I got that right lol

cinder sable
#

Personally I don't do this parse(..., 1) thing at all, I just write parseV1(...)/parseV2(...)/parse(...).

#

All this layer of abstraction gets you is that you have less things to import (which doesn't matter anyways because IDE auto import for you)

dusky wharf
#

The reason that doesn't really work is because we don't know what the version is within the xml at that point

#

And also why we had two different functions for parsing and converting

cinder sable
#

I'm saying that instead of writing parse(..., 1) you just write parseV1(...).

#

Nothing else is different.

dusky wharf
#

Ah right, I see

#

It's possible my OCD has over complicated the solution

cinder sable
#

Tbf you are far from the only person that falls into this trap

#

It is a broader pattern of "function return type depending on argument type" and a lot of libraries do it.

cinder sable
#

You don't want your library user to have to import 20 functions from your library, but for application code it doesn't matter.

dusky wharf
#

So you're suggesting I do something along the lines of:

function parse(body: string): Obj {}
function parseV1(body: string): Obj1 {}
function parseV2(body: string): Obj2 {}
function parseV3(body: string): Obj3 {}
cinder sable
#

Yes.

dusky wharf
#

Fair enough

cinder sable
#

You are already doing this but in the form of that parsers god object, you are just splitting it up and exporting individually.

dusky wharf
#

Perhaps as a halfway house I could have:

function parse(body: string): Obj[keyof Obj] {}
function parseToVersion<K extends keyof Obj>(body: string, version: K): Obj[K] {}
#

My suspicion of doing seperate functions/methods for each version is that there will be a fair amount of duplicated code

#

So I'll give this a go and see how I get on

cinder sable
#

There won't be any more duplication than what you already have

#

parseToVersion is an abstraction on top of individual functions, it strictly has more code not less.

#

But yeah it's fine if you think the tradeoff is worth it, this is type safe so at least it's not like overloads.

dusky wharf
#

Sorry for my ignorance, but when you say something isn't "type safe" what do you actually mean?

#

As when I did the overloading my IDE seemed perfectly happy deciphering what was being returned

cinder sable
#
function fn(arg: string): number
function fn(arg: number): string
function fn(arg: string | number) {
    return arg
}
#

Compiles just fine, yet it's wrong at runtime.

#

Your implementation is not checked against the signatures.

dusky wharf
#

And what are the potential negative outcomes of this?

#

I know using any is bad generally speaking

cinder sable
#

Wdym?

#

TS says fn(42) should return a string, yet at runtime it doesn't.

#

fn(42).toUpperCase() will crash your program at runtime even though TS says it's fine.

dusky wharf
#

Right ok, I think I see now

#

So in my example:

public parse(body: string): Obj[keyof Obj];
  public parse<K extends keyof Obj>(body: string, version: K): Obj[K];
  public parse<K extends keyof Obj>(body: string, version?: K): any {
    if (version) {
      return parsers[version](body);
    }

    return "";
  }

This would actually potentially result in an error when someone doesn't specify a version as it's returned a string which it is not complaining about

cinder sable
#

Yep, that code will compile, but parse(...) will always return a string and might not match Obj[keyof Obj]

dusky wharf
#

👍 Makes sense

cinder sable
#

Using overload means you are responsible for making sure your implementation is correct, which is not that hard to do when you first write it, but that might become an issue later when you refactor and things change without you knowing.

dusky wharf
#

Yeah, cool, thanks for the explanation

#

I might still do it any way as it keeps my OCD happy lol

#

Will I regret it later.... we'll find out

#

This doesn't seem to complain:

public async parse(body: string): Promise<Ern[keyof Ern]>;
  public async parse<K extends keyof Ern>(body: string, version: K): Promise<Ern[K]>;
  public async parse<K extends keyof Ern>(body: string, version?: K): Promise<Ern[keyof Ern]> {}
#

And it technically type safe, yes?

cinder sable
#

It's overload, it's automatically unsafe.

desert pewter
#

it's the code that goes between {} (which you left out there) that is not safe (as in it's not type checked against the overload signatures). here's an example