-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Literal types support #878
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
Comments
You could model those literal types using interfaces: interface MagicalItem {
magic: Boolean!
}
interface RangedItem {
ranged: Boolean!
}
interface MeeleItem {
meele: Boolean!
}
interface NeutralItemAptitude {
neutral: Boolean!
}
interface TechnologicalItemAptitude {
technological: Boolean!
}
interface MagicalItemAptitute {
magical: Boolean!
}
type Sword implements MeeleItem, NeutralItemAptitude {
meele: Boolean!
neutral: Boolean!
damage: Float!
}
type Rifle implements RangedItem, TechnologicalItemAptitude {
ranged: Boolean!
technological: Boolean!
damage: Float!
}
type Spellbook implements MagicItem, MagicalItemAptitude {
magic: Boolean!
magical: Boolean!
}
union Weapon = Sword | Rifle | Spellbook That would allow discriminating a generated union types with a if ('ranged' in item) {
// thing must implement the RangedItem interface ☑️
}
if ('magic' in item || 'technological' in item) {
// thing must implement either MagicItem or TechnologicalAptitude interface ☑️
} |
@n1ru4l that's not a solution, because it doesn't allow us to use exhaustive type check === solution is not type-safe, and even less type-safe than creating the types manually and casting them manually on API level. Consider a case, when devs will need to add a new |
Example code and "switch" alternative using if statements: Your described case where a new weapon type is introduced: As you can see the introduction of a new type results in a TypeScript error. Please also differentiate between "not the optimal solution that you desire" and "not a solution". As mentioned on Discord before, there is definitely some room for improvement here and literal types might be a possible solution for making this more convenient. |
now open your second link and change the union to if the solution requires to change the data model, introduce lots of unnecessary fields, throws away standard TS way to iterate over union but fails to detect the error anyway - that's not "not the optimal solution", that's just "not a solution" |
@PinkaminaDianePie This can be used as a workaround: ----> |
Regarding the proposal, could the introduction of exact enum field types be a possible solution? enum WeaponType {
melee
ranged
magic
}
enum ItemAptitude {
neutral
technological
magical
}
type Sword {
weaponType: WeaponType.melee!
aptitude: ItemAptitude.neutral!
damage: Float!
}
type Rifle {
weaponType: WeaponType.ranged!
aptitude: ItemAptitude.technological!
damage: Float!
}
type Spellbook {
weaponType: WeaponType.magic!
aptitude: ItemAptitude.magical!
damage: Float!
chargesLeft: Int!
} |
In my particular case, exact enums would solve all the issues I have. The only problem is that it works only with strings, but I saw some cases where people used number/boolean literals as discriminators. Not sure how common is this case, but that's probably a separate topic - allowing enum to have boolean/numeric values is a nice thing to have, but not mandatory to solve the issue we talk about. I'd be happy to see either solution |
+1 Exact enums would be a useful construct, particularly in the absence of generics. |
I created this branch where I added parser and printer support for exact enums, it was a fun experience diving into the parser and lexer: https://github.com/graphql/graphql-js/compare/main...n1ru4l:feat-exact-named-type-node?expand=1 This is still far from an RFC, but I am just experimenting a bit with it for now! |
Would a Union of Interfaces help? You can use the schema to automatically generate type predicates that check whether a received concrete type satisfies an interface based simply on the type name. This is similar to suggestion above, but avoids booleans |
@yaacovCR One drawback of that solution is that the functions can become quite large if there are a lot of types within the schema. This solution would also work with the boolean solution (but less verbose as type names tend to be longer). In contrast, the exact enums approach would not require generating any function code. |
Although I am in favor of literals in general mostly because of why not |
True. To clarify, you would need a generated predicate per interface, not per concrete implementation. The good news is that you would have symmetry between your runtime types and the graphql types instead of so that could be an organizational plus. Those predicates might be handy anyway. Imagine if you already had such an interface setup, for example. We are talking about introducing it to solve this problem, but I bet a lot of schemas already use interfaces, and such predicates would also help them. Maybe TypedDocumentNode would already solve this problem also... |
I don't understand how the union of interfaces would help. I want to specify that some particular interface has a field with literal type as a value. It doesn't matter if we have types or interfaces, autogenerated code, and so on, I just want to restrict the value of one particular field to a literal on a schema level to generate proper TS types and I can't do it. |
It wouldn't help you have a literal type for a field, but it would help you create a system for discriminating between related types on the client exhaustively using TS typings -- in this case identical to native graphql types. I thought that was the end goal. |
@yaacovCR Can you provide an example? It is really hard to grasp what you actually mean. When i try to interpret it it feels like what you are describing is the same as #878 (comment) |
@n1ru4l sort of -- I assumed that I could solve it using interfaces without realizing that we don't have empty marker interfaces and so we can't have an interfaces for RangedWeapon NeutralItem without dummy fields. I think that solution with using Apologies for the distraction. |
can you please expand on this? and why this:
wouldn't work? why multiple discriminators are needed? |
@rivantsov The first post shows a good example: #878 (comment) union Weapon = Sword | Rifle | Spellbook If all of those types have different properties (weaponType, aptitude) it is impossible to discriminate them based on only one or both of those properties. Using the typename allows to only safely discriminate one property (the type itself). |
sorry but still not getting it. Why __typeName not discriminates enough?! |
@rivantsov Today we only have one value (aside from doing structural checks for identifying what a type actually is). enum WeaponType {
melee
ranged
magic
}
enum ItemAptitude {
neutral
technological
magical
}
type Sword {
weaponType: WeaponType!
aptitude: ItemAptitude!
damage: Float!
}
type Rifle {
weaponType: WeaponType!
aptitude: ItemAptitude!
damage: Float!
}
type Crossbow {
weaponType: WeaponType!
aptitude: ItemAptitude!
damage: Float!
}
type Spellbook {
weaponType: WeaponType!
aptitude: ItemAptitude!
damage: Float!
chargesLeft: Int!
}
union Weapon = Sword | Rifle | Crossbow | Spellbook This does not allow us to get the exact value of the E.g. the The TypeScript representation of the example above could today only be expressed as the following: type WeaponType = "melee" | "ranged" | "magic";
type ItemAptitude = "neutral" | "technological" | "magical";
type Sword = {
__typename: "Sword";
weaponType: WeaponType;
aptitude: ItemAptitude;
damage: number;
};
type Rifle = {
__typename: "Rifle";
weaponType: WeaponType;
aptitude: ItemAptitude;
damage: number;
};
type Crossbow = {
__typename: "Crossbow";
weaponType: WeaponType;
aptitude: ItemAptitude;
damage: number;
};
type Spellbook = {
__typename: "Spellbook";
weaponType: WeaponType;
aptitude: ItemAptitude;
damage: number;
chargesLeft: number;
};
type Weapon = Sword | Rifle | Crossbow | Spellbook; Where as the literal type/exact enum type approach (from #878 (comment)) would allow generating types where the Schema with exact enum types enum WeaponType {
melee
ranged
magic
}
enum ItemAptitude {
neutral
technological
magical
}
type Sword {
weaponType: WeaponType.melee!
aptitude: ItemAptitude.neutral!
damage: Float!
}
type Rifle {
weaponType: WeaponType.ranged!
aptitude: ItemAptitude.technological!
damage: Float!
}
type Crossbow {
weaponType: WeaponType.ranged!
aptitude: ItemAptitude.neutral!
damage: Float!
}
type Spellbook {
weaponType: WeaponType.magic!
aptitude: ItemAptitude.magical!
damage: Float!
chargesLeft: Int!
}
union Weapon = Sword | Rifle | Crossbow | Spellbook typescript output type Sword = {
__typename: "Sword";
weaponType: "melee";
aptitude: "neutral";
damage: number;
};
type Rifle = {
__typename: "Rifle";
weaponType: "ranged";
aptitude: "technological";
damage: number;
};
type Crossbow = {
__typename: "Crossbow";
weaponType: "ranged";
aptitude: "neutral";
damage: number;
};
type Spellbook = {
__typename: "Spellbook";
weaponType: "magic";
aptitude: "magical";
damage: number;
chargesLeft: number;
};
type Weapon = Sword | Rifle | Crossbow | Spellbook; This allows more powerful type narowing: function printWeapon(weapon: Weapon) {
if (weapon.weaponType === "ranged") {
// weapon is narrowed down to Rifle or Sword
}
// instead of doing
if (weapon.__typename === "Rifle" || weapon.__typename === "Crossbow") {
// weapon is narrowed down to Rifle or Sword
}
// or
if (weapon.weaponType === "neutral") {
// weapon is narrowed down to Crossbow or Sword
}
// instead of
if (weapon.__typename === "Sword" || weapon.__typename === "Crossbow") {
// weapon is narrowed down to Crossbow or Sword
}
} Thus the arguments for introducing it into GraphQL would be:
Whether it shall be introduced as exact enums or actual literal types is still somethign to discuss though 😄 |
Suggesting simpler alternative: Constant Fields thanks for the explanation. Now I get it, kind of. But then what you need is not 'literal types' but Constant fields , or fields with 'default-forever-value'. So instead of: type Spellbook {
weaponType: WeaponType.magic!
... ...
} we write: type Spellbook {
weaponType: WeaponType! = Magic
... ...
} 'Literal type' is an advanced type concept, not widespread, I had to google to find out WTF is that, found references to TypeScript and C++. I might be a dumb expert (I am), not knowing ALL stuff, but that makes me a good test case here - not everybody is familiar with literal types concept. It took me a while to get it what you suggest and how it will work. At the same time, 'constant fields' is an easy, trivial concept to understand for everybody, even a dummy like myself |
constant fields is a harder concept than literal types and it makes things more complicated. why do I need to create enum just to express that my field has some specific value? type Sword {
weaponType: "melee"!
aptitude: "neutral"!
damage: Float!
}
type Rifle {
weaponType: "ranged"!
aptitude: "technological"!
damage: Float!
}
type Spellbook {
weaponType: "magic"!
aptitude: "magical"!
damage: Float!
chargesLeft: Int!
}
union Weapon = Sword | Rifle | Spellbook with constant fields it would look like this: enum WeaponType {
melee
ranged
magic
}
enum ItemAptitude {
neutral
technological
magical
}
type Sword {
weaponType: WeaponType! = melee
aptitude: ItemAptitude! = neutral
damage: Float!
}
type Rifle {
weaponType: WeaponType! = ranged
aptitude: ItemAptitude! = technological
damage: Float!
}
type Spellbook {
weaponType: WeaponType! = magic
aptitude: ItemAptitude! = magical
damage: Float!
chargesLeft: Int!
}
union Weapon = Sword | Rifle | Spellbook it takes much more code and it also requires creating 2 additional enums I will never need in my code. Of course, I can create a dummy enum for all literal values like this: enum Literal {
melee
ranged
magic
neutral
technological
magical
}
type Sword {
weaponType: Literal! = melee
aptitude: Literal! = neutral
damage: Float!
}
type Rifle {
weaponType: Literal! = ranged
aptitude: Literal! = technological
damage: Float!
}
type Spellbook {
weaponType: Literal! = magic
aptitude: Literal! = magical
damage: Float!
chargesLeft: Int!
}
union Weapon = Sword | Rifle | Spellbook but still why create an additional enum and have more code than needed? yes, ultimately it would solve the purpose to generate proper TS types from such a schema, but constant fields looks like unnecessary complexity Edit: I just realized that nobody forces me to use enums here :D type Sword {
weaponType: String! = melee
aptitude: String! = neutral
damage: Float!
}
type Rifle {
weaponType: String! = ranged
aptitude: String! = technological
damage: Float!
}
type Spellbook {
weaponType: String! = magic
aptitude: String! = magical
damage: Float!
chargesLeft: Int!
}
union Weapon = Sword | Rifle | Spellbook So version with constants is actually just a few characters longer that with literals |
but much simpler conceptually |
by the way, the same can be achieved today, with a custom directive: |
I can't agree that constants is a simpler context though. I speak from a frontend perspective (TS/Flow), where literal types are widely used (in all the codebases I worked with they are even more common than enums). Constants still require some additional mental work: "first we declared type as a set of values, but then narrowed it down to a single value" vs "we declared type as a single value". Why should I narrow it down, if I can properly specify it straight away? What this narrowing down implies? Is it safe to assume that here |
There is only the limitation that the directives have no proper "on field type validation" and are not visible via introspection |
directives on fields are not visible on fields - As for 'literal types' easy - good for you guys you spend most of your time in typeScript. I don't. I am a server side guy, never encountered it. The concept is not trivial. "ABC" is a type - whaaat? Constant fields are easier for sure. In c#, java etc, constants are static immutable fields with initial values. What "=" means? same thing as for args with default values, this is in line with existing syntax. |
For default values, you need the type because when supplied, the argument must match the type. So the question becomes, with literals or constants, for the executor to validate the object against the type, is a type necessary? I think it might be necessary for something like a constant custom scalar. But if you just want to express the literal types that can be in sdl like string and it's equivalents (ID, enum) you wouldn't need it. |
Hello all, kinda late to the convo and sadly, there hasn't been any activity on this for almost a year. Just wanted to share I found this because I too found myself needing GraphQL support for Typescript's discriminated unions, especially within the context of codegen tools like this one. When I first started my project, I was manually maintaining TS types alongside a GraphQL schema that somewhat reflected these types but as my project began to grow, I found that this is a painstaking task and source of many bugs, so I decided to try and adopt usage of previously linked codegen tool. What I am finding is that discriminated unions are really difficult to model in a GraphQL schema, and the best idea I've had on how to do so is using custom scalars like:
And then, within my
However, as I'm still relatively new to GraphQL and currently using Apollo Server/Client tools, I've read I need to implement custom scalar logic, and since I'm using discriminated unions a fair bit, this might become quite a pain (not sure yet though). I tried making use of the
So, excuse the long post but I don't really want to rewrite a substantial part of my codebase that depended on discriminated unions so I'm hoping someone can suggest the best way to approach this problem or at the very least, be another scenario where GraphQL support for discriminated unions (however that may be, through literal types, exact enum types, or constant fields). |
Bumping this topic as it's been a minute. Reading through the discussion above, @n1ru4l seems to have summarized the problem (and proposed solution) pretty well—is there any substantial objection to advancing this in the spec? If the concerns mentioned above are more semantic than functional in nature, I think both "literal types" and "constant fields" would be perfectly suitable names (naming is one of the hardest things, after all). 😉 Either way, the feature itself seems to fall into the "high impact/low effort" category. If it's just traction this needs, happy to help—we could definitely benefit from this too. FWIW, I do think the dot notation (example A) does feel a bit more "natural" and universally understood than using the assignment operator/"equals" syntax (example B), as it can have notably different uses across languages, as @PinkaminaDianePie and @rivantsov both eluded to. # Example A:
type Foo {
bar: MyEnum.VALUE!
}
# Example B:
type Foo {
bar: MyEnum! = VALUE
} |
Any update on this? Discriminated unions are such a useful capability in typescript. When using GQL codegen to make typescript types we don't have this option, so we can't use it. Our codebase would really benefit from it. Support for simple literal values as proposed by @PinkaminaDianePie would seem the most elegant to me:
|
@justin-caldicott GraphQL built in a discriminant union on all object types, |
Thanks for clarifying @jasonkuhrt. Being able to use any property as the discriminator, rather than just |
Consider adding literal types support to be able to express a union with more than a single discriminator:
__typename
is useless in a case where multiple discriminators are needed. Multiple discriminators allow us to write more type-safe code, using exhaustive type check.Currently, the only partial alternative is to use enums:
The problem is that it doesn't allow to use of different properties as union discriminators. Consider such a react component:
here I know that if type of weapon is magical, I need to display
chargesLeft
property. however, it's impossible to represent it in graphql, so it's also impossible to generate a typescript type from a schema.The only solutions left is to give up on type safety and hope that every developer won't forget to check every case, or just give up on generating types from the graphql schema and instead write all of them manually, casting graphql query results to manual typings on API level. Any of these solutions have multiple points of failure, so there is no easy and safe way to represent such kind of data at this moment.
The text was updated successfully, but these errors were encountered: