Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions docs/docs/types/variants.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,38 @@ While variants are still very efficient, they need runtime-checks for type conve
which comes with a tiny overhead compared to all other statically defined types. If possible, **avoid variants**.
:::

## No literal values
## Variants with String Enums

A variant can only consist of types, not of literal values.
Variants can contain [string enums (unions)](custom-enums#typescript-union) alongside other types like `boolean` or `number`:

```ts
type Status = 'pending' | 'complete' | 'failed'

interface Task extends HybridObject<{ … }> {
getStatus(): boolean | Status
}
```

This generates a proper variant type (`std::variant<bool, Status>` in C++) where `Status` is preserved as an enum.

You can also combine multiple string enums in a single variant:

```ts
type Color = 'red' | 'green' | 'blue'
type Size = 'small' | 'medium' | 'large'

interface Item extends HybridObject<{ … }> {
getAttribute(): Color | Size
}
```

:::warning
String enums in the same variant must have **distinct values**. Overlapping values (e.g., both enums containing `'default'`) will cause an error because Nitrogen cannot determine which enum a shared value belongs to at runtime.
:::

## No inline literal values

A variant can only consist of types, not of inline literal values.

```ts
export interface Person extends HybridObject<{ … }> {
Expand Down
4 changes: 4 additions & 0 deletions packages/nitrogen/src/nitrogen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import chalk from 'chalk'
import { groupByPlatform, type SourceFile } from './syntax/SourceFile.js'
import { Logger } from './Logger.js'
import { NitroConfig } from './config/NitroConfig.js'
import { initializeTypeCreation } from './syntax/createType.js'
import { createIOSAutolinking } from './autolinking/createIOSAutolinking.js'
import { createAndroidAutolinking } from './autolinking/createAndroidAutolinking.js'
import type { Autolinking } from './autolinking/Autolinking.js'
Expand Down Expand Up @@ -61,6 +62,9 @@ export async function runNitrogen({
})
project.addSourceFilesAtPaths(globPattern)

// Initialize type creation context (needed for enum reconstruction from flattened unions)
initializeTypeCreation(project)

// Loop through all source files to log them
Logger.info(
chalk.reset(
Expand Down
165 changes: 155 additions & 10 deletions packages/nitrogen/src/syntax/createType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ts, Type as TSMorphType, type Signature } from 'ts-morph'
import {
ts,
Type as TSMorphType,
type Project,
type Signature,
type TypeAliasDeclaration,
} from 'ts-morph'
import type { Type } from './types/Type.js'
import { BooleanType } from './types/BooleanType.js'
import { NumberType } from './types/NumberType.js'
Expand Down Expand Up @@ -45,6 +51,27 @@
import { compareLooselyness } from './helpers.js'
import { NullType } from './types/NullType.js'

// Cache of type alias name -> TypeAliasDeclarations for efficient lookups during enum reconstruction.
// Multiple declarations may exist for the same name across different files.
let typeAliasCache: Map<string, TypeAliasDeclaration[]> | undefined

/**
* Initialize the type creation context with the ts-morph Project.
* Must be called before createType() to enable enum reconstruction from flattened unions.
*/
export function initializeTypeCreation(project: Project): void {
// Build type alias cache for efficient lookups
typeAliasCache = new Map()
for (const sf of project.getSourceFiles()) {
for (const ta of sf.getTypeAliases()) {
const name = ta.getName()
const existing = typeAliasCache.get(name) ?? []
existing.push(ta)
typeAliasCache.set(name, existing)
}
}
}

function getHybridObjectName(type: TSMorphType): string {
const symbol = isHybridView(type) ? type.getAliasSymbol() : type.getSymbol()
if (symbol == null) {
Expand Down Expand Up @@ -75,6 +102,81 @@
})
}

/**
* When TypeScript flattens a union like `boolean | FooBar` into `boolean | 'foo' | 'bar'`,
* we lose the information that 'foo' and 'bar' came from the named type `FooBar`.
* This function reconstructs enum types by parsing the type text for named types
* and matching their values against the string literals in the union.
*/
function reconstructEnumTypesFromStringLiterals(
_type: TSMorphType,
stringLiterals: TSMorphType[]
): { enumTypes: EnumType[]; uncoveredLiterals: TSMorphType[] } {
const stringLiteralValues = new Set(stringLiterals.map((t) => t.getText()))
const coveredLiterals = new Set<string>()
const enumTypes: EnumType[] = []

if (typeAliasCache == null) {
throw new Error(
'initializeTypeCreation() must be called before processing types with string literal unions'
)
}

// Collect all candidate enums whose values are all present in our string literals
const candidateEnums: { name: string; enumType: EnumType; values: Set<string> }[] = []

Check warning on line 126 in packages/nitrogen/src/syntax/createType.ts

View workflow job for this annotation

GitHub Actions / Lint TypeScript (eslint, prettier)

Replace `·name:·string;·enumType:·EnumType;·values:·Set<string>` with `⏎····name:·string⏎····enumType:·EnumType⏎····values:·Set<string>⏎·`

for (const typeAliases of typeAliasCache.values()) {
for (const typeAlias of typeAliases) {
const aliasType = typeAlias.getType()
if (!aliasType.isUnion() || !aliasType.getAliasSymbol()) continue

const aliasUnionTypes = aliasType.getUnionTypes()
const isStringLiteralEnum = aliasUnionTypes.every((t) =>
t.isStringLiteral()
)
if (!isStringLiteralEnum) continue

const enumValues = aliasUnionTypes.map((t) => t.getText())
const allValuesPresent = enumValues.every((v) =>
stringLiteralValues.has(v)
)
if (!allValuesPresent) continue

candidateEnums.push({
name: typeAlias.getName(),
enumType: new EnumType(typeAlias.getName(), aliasType),
values: new Set(enumValues),
})
}
}

// Check for overlapping enums - if any two candidate enums share a value, it's ambiguous
for (let i = 0; i < candidateEnums.length; i++) {
for (let j = i + 1; j < candidateEnums.length; j++) {
const enumA = candidateEnums[i]!
const enumB = candidateEnums[j]!
for (const value of enumA.values) {
if (enumB.values.has(value)) {
throw new Error(
`Cannot create variant with overlapping string enum types: ${enumA.name} and ${enumB.name} both contain ${value}. Use enums with distinct values.`
)
}
}
}
}

// No overlaps - add all candidate enums
for (const candidate of candidateEnums) {
enumTypes.push(candidate.enumType)
candidate.values.forEach((v) => coveredLiterals.add(v))
}

const uncoveredLiterals = stringLiterals.filter(
(t) => !coveredLiterals.has(t.getText())
)
return { enumTypes, uncoveredLiterals }
}

type Tuple<
T,
N extends number,
Expand Down Expand Up @@ -287,25 +389,68 @@
)
const isEnumUnion = nonNullTypes.every((t) => t.isStringLiteral())
if (isEnumUnion) {
// It consists only of string literaly - that means it's describing an enum!
// It consists only of string literals - that means it's describing an enum!
const symbol = type.getNonNullableType().getAliasSymbol()
if (symbol == null) {
// If there is no alias, it is an inline union instead of a separate type declaration!
throw new Error(
`Inline union types ("${type.getText()}") are not supported by Nitrogen!\n` +
`Extract the union to a separate type, and re-run nitrogen!`
)
// No alias symbol - could be multiple enum types flattened together (e.g., SomeEnum | SomeOtherEnum)
// Try to reconstruct the original enum types from the string literals
const { enumTypes, uncoveredLiterals } =
reconstructEnumTypesFromStringLiterals(type, nonNullTypes)

if (enumTypes.length === 0 || uncoveredLiterals.length > 0) {
// Could not reconstruct enums - it's an inline union
throw new Error(
`Inline union types ("${type.getText()}") are not supported by Nitrogen!\n` +
`Extract the union to a separate type, and re-run nitrogen!`
)
}

if (enumTypes.length === 1) {
// Single enum reconstructed
return enumTypes[0]!
}

// Multiple enums - return as variant
const name = type.getAliasSymbol()?.getName()
return new VariantType(enumTypes, name)
}
const typename = symbol.getEscapedName()
return new EnumType(typename, type)
} else {
// It consists of different types - that means it's a variant!
let variants = type
const unionTypes = type
.getUnionTypes()
// Filter out any undefineds/voids, as those are already treated as `isOptional`.
.filter((t) => !t.isUndefined() && !t.isVoid())
.map((t) => createType(language, t, false))
.toSorted(compareLooselyness)

// Separate string literals from other types
// String literals might belong to a named enum type that got flattened
const stringLiterals = unionTypes.filter((t) => t.isStringLiteral())
const otherTypes = unionTypes.filter((t) => !t.isStringLiteral())

let variants: Type[] = []

// Process non-string-literal types
for (const t of otherTypes) {
variants.push(createType(language, t, false))
}

// For string literals, try to find named enum types from the original type text.
// TypeScript flattens `boolean | FooBar` into `boolean | 'foo' | 'bar'`, losing the
// enum type info. We reconstruct it by parsing the type text for named types.
if (stringLiterals.length > 0) {
const { enumTypes, uncoveredLiterals } =
reconstructEnumTypesFromStringLiterals(type, stringLiterals)
variants.push(...enumTypes)

if (uncoveredLiterals.length > 0) {
throw new Error(
`String literal ${uncoveredLiterals[0]!.getText()} cannot be represented in C++ because it is ambiguous between a string and a discriminating union enum.`
)
}
}

variants = variants.toSorted(compareLooselyness)
variants = removeDuplicates(variants)

if (variants.length === 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,26 @@ class HybridTestObjectKotlin : HybridTestObjectSwiftKotlinSpec() {
return variant
}

override fun getVariantSomeEnum(variant: Variant_Boolean_SomeEnum): Variant_Boolean_SomeEnum {
return variant
}

override fun getVariantMultipleEnums(variant: Variant_SomeEnum_SomeOtherEnum): Variant_SomeEnum_SomeOtherEnum {
return variant
}

override fun getVariantStringAndEnum(variant: String): String {
return variant
}

override fun getVariantThreeTypes(variant: Variant_Boolean_SomeEnum_SomeOtherEnum): Variant_Boolean_SomeEnum_SomeOtherEnum {
return variant
}

override fun getVariantNumberAndEnum(variant: Variant_SomeEnum_Double): Variant_SomeEnum_Double {
return variant
}

override fun getVariantObjects(variant: Variant_Car_Person): Variant_Car_Person {
return variant
}
Expand Down
20 changes: 20 additions & 0 deletions packages/react-native-nitro-test/cpp/HybridTestObjectCpp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,26 @@ std::variant<bool, WeirdNumbersEnum> HybridTestObjectCpp::getVariantWeirdNumbers
return variant;
}

std::variant<bool, SomeEnum> HybridTestObjectCpp::getVariantSomeEnum(const std::variant<bool, SomeEnum>& variant) {
return variant;
}

std::variant<SomeEnum, SomeOtherEnum> HybridTestObjectCpp::getVariantMultipleEnums(const std::variant<SomeEnum, SomeOtherEnum>& variant) {
return variant;
}

std::string HybridTestObjectCpp::getVariantStringAndEnum(const std::string& variant) {
return variant;
}

std::variant<bool, SomeEnum, SomeOtherEnum> HybridTestObjectCpp::getVariantThreeTypes(const std::variant<bool, SomeEnum, SomeOtherEnum>& variant) {
return variant;
}

std::variant<SomeEnum, double> HybridTestObjectCpp::getVariantNumberAndEnum(const std::variant<SomeEnum, double>& variant) {
return variant;
}

std::variant<Car, Person> HybridTestObjectCpp::getVariantObjects(const std::variant<Car, Person>& variant) {
return variant;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native-nitro-test/cpp/HybridTestObjectCpp.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ class HybridTestObjectCpp : public HybridTestObjectCppSpec {

std::variant<bool, OldEnum> getVariantEnum(const std::variant<bool, OldEnum>& variant) override;
std::variant<bool, WeirdNumbersEnum> getVariantWeirdNumbersEnum(const std::variant<bool, WeirdNumbersEnum>& variant) override;
std::variant<bool, SomeEnum> getVariantSomeEnum(const std::variant<bool, SomeEnum>& variant) override;
std::variant<SomeEnum, SomeOtherEnum> getVariantMultipleEnums(const std::variant<SomeEnum, SomeOtherEnum>& variant) override;
std::string getVariantStringAndEnum(const std::string& variant) override;
std::variant<bool, SomeEnum, SomeOtherEnum> getVariantThreeTypes(const std::variant<bool, SomeEnum, SomeOtherEnum>& variant) override;
std::variant<SomeEnum, double> getVariantNumberAndEnum(const std::variant<SomeEnum, double>& variant) override;
std::variant<Car, Person> getVariantObjects(const std::variant<Car, Person>& variant) override;
std::variant<std::shared_ptr<HybridTestObjectCppSpec>, Person>
getVariantHybrid(const std::variant<std::shared_ptr<HybridTestObjectCppSpec>, Person>& variant) override;
Expand Down
24 changes: 24 additions & 0 deletions packages/react-native-nitro-test/ios/HybridTestObjectSwift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,30 @@ class HybridTestObjectSwift: HybridTestObjectSwiftKotlinSpec {
return variant
}

func getVariantSomeEnum(variant: Variant_Bool_SomeEnum) throws -> Variant_Bool_SomeEnum {
return variant
}

func getVariantMultipleEnums(variant: Variant_SomeEnum_SomeOtherEnum) throws
-> Variant_SomeEnum_SomeOtherEnum
{
return variant
}

func getVariantStringAndEnum(variant: String) throws -> String {
return variant
}

func getVariantThreeTypes(variant: Variant_Bool_SomeEnum_SomeOtherEnum) throws
-> Variant_Bool_SomeEnum_SomeOtherEnum
{
return variant
}

func getVariantNumberAndEnum(variant: Variant_SomeEnum_Double) throws -> Variant_SomeEnum_Double {
return variant
}

func getVariantObjects(variant: Variant_Car_Person) throws -> Variant_Car_Person {
return variant
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ target_sources(
../nitrogen/generated/android/c++/JVariant_______Unit_Double.cpp
../nitrogen/generated/android/c++/JVariant_Boolean_OldEnum.cpp
../nitrogen/generated/android/c++/JVariant_Boolean_WeirdNumbersEnum.cpp
../nitrogen/generated/android/c++/JVariant_Boolean_SomeEnum.cpp
../nitrogen/generated/android/c++/JVariant_SomeEnum_SomeOtherEnum.cpp
../nitrogen/generated/android/c++/JVariant_Boolean_SomeEnum_SomeOtherEnum.cpp
../nitrogen/generated/android/c++/JVariant_SomeEnum_Double.cpp
../nitrogen/generated/android/c++/JVariant_Car_Person.cpp
../nitrogen/generated/android/c++/JVariant_HybridBaseSpec_OptionalWrapper.cpp
../nitrogen/generated/android/c++/JCoreTypesVariant.cpp
Expand Down
Loading
Loading